getter vs publicフィールド

Javaでgetter/setterを常につけるのは、フィールドをpublicで宣言するのと何が違うのか、という話題がTwitterであった。

賛否両論あったが、自分の今の意見をまとめておく。(現LTSであるJava11を念頭に考えているため、Java17以降にはrecordを使うケースが増えるだろう)

結論としては、getterを用意する派である。

当初の自分自身の意見

当初の自分自身の意見としては、Lombokを使えば書きづらさも読みづらさも問題ないので、getterをつければいい派だった。(setterについてはイミュータブル等の話題に広がっていってしまうので、あえてあまり話題にしない)

強いこだわりを持っていたわけではない。

Seasar2が流行っていた頃にも、この話題はあちこちでされていて、Lombokを取り入れていなかったこの頃はpublicフィールド派の意見に結構賛同していた。

JSONクラス等の場合は値をAPサーバで書き換える必要がないことが大半で、public final field だけ持ったデータクラスで済ませたいというような意見には「finalにすればsetterを用意しないイミュータブルクラスのようなものだし、よさそうだ」と思った。

public派の意見

public派の意見としては、「常にgetter/setterをつけているのはpublicであることと実質同じなのでgetter/setterを考えなしにつけるのは無駄で、publicで宣言した方がシンプル」というもの。

先程も記載した通り、public finalであればイミュータブルなクラスにもできる。

getterとinterfaceを組み合わせると便利

若干public派の意見に傾きそうになっていたところ、以下のTweetを読み、interfaceと組み合わせてgetterの恩恵にあずかった経験があることに気付いた。

JSONのList内に複数種類のデータを混ぜるときのJava実装

JSONの概要

JSONのList項目の中に少し違うデータを混ぜるときにJava側のクラスをどのように宣言するかについて考える。

今回表現したいJSONは以下の通り。

[
  {
    "type": "PENCIL",
    "price": 100,
    "hardness": "HB"
  },
  {
    "type": "PEN",
    "price": 500,
    "point": "0.3"
  }
]

動的言語でなく、さらに歴史の古いJavaであまりこのようなデータの持ち方はしないかもしれないが、JSONはサーバーとフロントの境界や他システムとの境界で使うので、Javaでもシンプルに表現できるようにしたい。

JSONライブラリはJackson(version: 2.10.2)を使う。

Listに入れる全クラスにinterfaceを付与する

Listに入れるクラス(Pencil, Pen)を共通に扱えるように、同じinterface(WritingTool)を付与する。

@Data
@AllArgsConstructor
@NoArgsConstructor
public class WritingToolContainer {

    private WritingTool writingTool;

    public interface WritingTool {
        String getType();

        int getPrice();
    }
    
    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class Pencil implements WritingTool {
        private final String type = "PENCIL";
        private int price;
        private String hardness;
    }

    @Data
    @AllArgsConstructor
    @NoArgsConstructor
    public static class Pen implements WritingTool {
        private final String type = "PEN";
        private int price;
        private String point;
    }

}

interfaceには共通で必ず持たなければいけない項目を設定している。

Listの総称型にinterfaceを指定する

List<WritingTool>listを宣言すれば、listにはPencilのオブジェクトでもPenのオブジェクトでも入れることができる。

public class Main {

    static ObjectMapper objectMapper = new ObjectMapper();

    public static void main(String[] args) throws JsonProcessingException {

        var pencils = getPencilsBySomeLogic();
        var pens = getPensBySomeLogic();

        var list = new ArrayList<WritingToolContainer.WritingTool>();
        list.addAll(pencils);
        list.addAll(pens);

        String json = objectMapper.writeValueAsString(list);
        System.out.println(json);
    }

    private static List<Pencil> getPencilsBySomeLogic() {
        return List.of(new Pencil(100, "HB"));
    }

    private static List<Pen> getPensBySomeLogic() {
        return List.of(new Pen(500, "0.3"));
    }
}

interfaceで定義している共通の必須メソッドを活用する

interfaceで定義したgetterメソッドについては、実際の具象型がなんであれ使用できるので、listの各要素のpriceを元にソートするコードなどがかける。

list.stream()
    .sorted(Comparator.comparing(WritingToolContainer.WritingTool::getPrice))
    .collect(Collectors.toList());

また今回は導入しなかったが、JSONに出力する項目以外のgetterを宣言してもいいし、defaultメソッドを用意してもいい。

JSONに出力する項目以外のgetterを宣言した場合は、@JsonIgnoreを具象型のフィールドにつけるといい。

JSON -> Javaにデシリアライズ

本題とはずれるが、Java -> JSONへのシリアライズについて記載してきたので、反対のJSON -> Javaへのデシリアライズについても触れておく。

通常のデシリアライズでは、特に何も意識しなくてもreadValueメソッドを使えば、JacksonがJSON文字列からJavaのクラスにマッピングしてくれる。

var listDeserialized =
        objectMapper.readValue(json,
                               new TypeReference<List<WritingToolContainer.WritingTool>>() {});

しかし、ポリモーフィズムを用いてクラス定義している場合は、JacksonがJSON文字列を見ても、そのデータがどの具象型にマッピングできるのか判別できず、例外(com.fasterxml.jackson.databind.exc.InvalidDefinitionException)が発生する。

エラー文には "abstract types either need to be mapped to concrete types, have custom deserializer, or contain additional type information" と記載されており、抽象型を具象型にマッピングできるようにしなければならないことがわかる。対応方法はいくつかあるが、ヒント用のアノテーションを設定するのが一番わかりやすい。

interface定義の上に@JsonTypeInfo@JsonSubTypesを付与する。

    @JsonTypeInfo(use = Id.NAME, property = "type")
    @JsonSubTypes({
        @Type(name = "PENCIL", value = Pencil.class),
        @Type(name = "PEN", value = Pen.class)
    })
    public interface WritingTool {
        String getType();

        int getPrice();
    }

本題とはずれるのであまり詳しくは書かないが、@JsonTypeInfotypeフィールドの値によって判別することを示し、@JsonSubTypesでその値が何であれば何のクラスにマッピングするかを示している。

まとめ

あまり型が綺麗でないJSONの場合、それをどのようにJavaで表現するかというお題に対してポリモーフィズムとinterfaceを持ち出して解決した。もしgetterを忌み嫌って避けていたら、この解決方法は使えない。getterを用意していれば用意していない場合と比べて容易に解決できることは今回の例以外にもおそらくあるだろう。

「getterとpublicフィールドを時と場合によって使い分ける」というもっともらしいことも言えるが、一プロジェクト内にpublicフィールドとgetterが混ざっているのも気持ち悪いと思う。

Lombokのおかげで簡単にgetterを定義できるようになったのだから、あまりgetterを嫌って排除せずに普通に使うのが、一番柔軟なのではないか。