日々常々

ふつうのプログラマがあたりまえにしたいこと。

ローカルクラスとしてrecordを使う

irof.hateblo.jp

こちらで書いた「Local record classes」を使う時の注意。

触ってると案外使う機会が出てきそうな感触があったので、気をつけるところを書いておきます。

ローカルクラス

そもそもローカルクラスってあまり使わないものなんで存在も認識されてなかったりします。メソッド内に書くクラスなんですが……

class Hoge {

    void fuga() {
        class Piyo { } // これ
    }
}

書いたら「こんなとこにクラス書けるの?」な反応もしばしば。 用途は特殊だし代替手段もあるので無理に使う必要性はないです。知らなくても生きてけるし、知ってたからと言って 知識マウントに使うにも微妙で 嬉しいことってそんなにない。 サンプルコード書く時にちょっと楽なくらいです。

ローカルレコードクラスの使い所

と言う感触だったんですが、これがStream APIに加えてrecordで若干変わりました。recordのネスト、ついでに他も でコンストラクタ全部出力するコードでも使ってます。

  • 名前付きのPair(やタプル)として作成できる
  • classだからメソッドも持たせられる

あたりが素敵です。 ローカルクラスでもできたんですけど、やろうとするとフィールドとコンストラクタを作らなきゃなので、メソッド内に書くには大袈裟でした。なのでまともにクラスを作るか、いろんなことに目を瞑って Object[] を使ってきました。record だとこの辺要らないので、メソッド内でもノイズがない手触りが良いです。

クラスを作りたくない理由は、そこまで広いコンテキストで使いたくないから。パッケージプライベートでも広いし、クラスにネストしたprivateクラスでもまだ広いかなって思うことがある。名前汚染みたいなことも気になっちゃう。 メソッドのローカルに閉じられると名前もメソッド内に特化したもので良くなるし、多少雑にやっても他を汚さないから取り扱い易いんです。

ローカルクラスとの違い

recordはクラスではあるんですが、classとイコールではないです。 特にrecordのネスト、ついでに他も で書いたstaticの扱い。 これがローカルの場合も入ってきます。

void method() {
    record LocalRecord() { }
    class LocalClass { }
}

コンパイルは通ります。同じように見えますが、LocalRecordstatic で、LocalClassは違います。 これが何を意味するかと言うと、「それぞれから外側にアクセスできるか」です。

void method() {
    var object = new Object();

    record LocalRecord() {
        void method() {
            object.toString();  // コンパイルエラー
        }
    }
    class LocalClass {
        void method() {
            object.toString();
        }
    }
}

これが本稿の主題です。ローカルクラスと同じ場所でローカルレコードクラスを使った場合、ローカルクラスと同等には使用できません。 「ローカルクラス」としてではなく「メソッド内に閉じたrecord」と認識する必要があります。

当然、メソッドの一時変数だけでなく、外側のクラスのインスタンスメンバにもrecordの内側からはアクセスできません。 recordで使用するものはレコードコンポーネントとして明示的に渡してあげる必要があります。

「ローカルレコードクラスでメソッドを使わなければ問題ない」ではありますが、メソッドを持てるのはただのデータ構造に対するせっかくのメリット。 取りまとめて名前までつけた情報の組み合わせには固有のメソッドがある。はず。たぶん。

もうちょい詳しく

エラーメッセージは Non-static variable 'object' cannot be referenced from a static context です。 static なんて書いてないけど、recordstatic context ということ。

この辺は javap で出てきたりします。以下は javap -v の抜粋。

SourceFile: "Sample.java"
NestMembers:
  Sample$1LocalClass
  Sample$1LocalRecord
InnerClasses:
  #20= #15;                               // LocalClass=class Sample$1LocalClass
  static final #21= #17;                  // LocalRecord=class Sample$1LocalRecord

同じように書いてるのに LocalRecord のほうは static final がついてます。

明示したい

「暗黙に任せずに明示したい」と思うかもしれませんが、以下はいずれもコンパイルエラーです。

void method() {
    static record LocalRecord() { }
    static class LocalClass { }
}

ローカルクラスにstaticはつけれないので、 static record は書けません。「デフォルトがどっち」とかではなく「強制的にこっち」なので明示云々じゃないんですね。

たぶんrecord はレコードコンポーネント以外を持たないのを優先したんでしょう。外側にアクセスするためには外側からインスタンスを受け取るようにコンストラクタを変えなきゃですし。 recordとしては正しいと思うけど、インナークラスとかに対する半端な知識があると混乱ポイントかなと思います。

蛇足: Stream APIのLambda内レコード

これってどうなんだ?と思いつつ。

Stream.of("abc", "defg")
        .map(s -> {
            record Pair(String value, int length) {}
            return new Pair(s, s.length());
        })
        .forEach(System.out::println);

StreamAPIの途中でrecordを宣言もできます。staticなんで後続処理にインスタンス渡してもインナークラスインスタンスを渡した時のようにメモリリークの原因にもならないはず。 後続処理(ここではforEachSystem.out::println)がPairコンパイルレベルで必要としなければ普通にPairとして動作します。

Pair を以下のように書けば、当然出力も変わります。

record Pair(String value, int length) {
    @Override
    public String toString() {
        return "きゃー";
    }
}

もちろんですが、Pair 独自のメソッドは後続ではコンパイルできません。

Stream.of("abc", "defg")
        .map(s -> {
            record Pair(String value, int length) {}
            return new Pair(s, s.length());
        })
        .map(Pair::value)  // コンパイルエラー
        .forEach(System.out::println);

record定義を前に出せば当然いけます。

record Pair(String value, int length) {}
Stream.of("abc", "defg")
        .map(s -> {
            return new Pair(s, s.length());
        })
        .map(Pair::value)
        .forEach(System.out::println);

けどこれは面白くない・・・あ、そうだ。java.util.functionのインタフェースを使おう。

Stream.of("abc", "defg")
        .map(s -> {
            record Pair(String value, int length) implements Supplier<String> {

                @Override
                public String get() { return "さぷらい"; }
            }
            return new Pair(s, s.length());
        })
        .map(Supplier::get)
        .forEach(System.out::println);

いけた。よし! toStringが呼べたからObjectとかの識別はできてたんだろけど、前段のPairSupplierってのをmapの時にわかるんですね。すごいなぁ。

……できるのは分かったけど、どう見てもわかりづらいし、お蔵入りかなー。

おまけ: ローカルenum

書けたのか……いやこれこそ書いてどうすんだ?

void method() {
    enum MyLocalEnum { HOO }
}

説明は省きますが、これもローカルレコードクラスと同じくstatic-contextになります。 「書いてどうすんだ?」とか書いたけど、ちょっと思いついてしまった。いつか試す。

recordのネスト、ついでに他も

record Outer(int a) {
    record Nest(int b) {
    }
}

こんなことしたらどうなるんだろうと気になりまして。

record が単にクラスのシンタックスシュガーであれば、NestはインナークラスになるのでOuterインスタンス変数、ここでは a にアクセスできるんですが、単なるネストクラスであればアクセスできません。 結論としてはNestからaにはアクセスできず、NestOuterインスタンスを持たない、staticが省略されていると言うことになります。

ネストだのインナーだのは 匿名クラスとかローカルクラスとか を参照ください。

確認はNestの方にメソッド生やしてOuterインスタンスメンバにアクセスしてみようとすればいいんですが、他にもNestjavapかリフレクションでコンストラクタやフィールドを見て、Outerがあるかどうかを見ればわかります。

そいや他でもどうなるんだろ?とこんなの書いて確認。

class OuterClass {
    class NestClass { }
    interface NestInterface { }
    enum NestEnum { }
    record NestRecord() { }
    static class StaticNestClass { }
    static interface StaticNestInterface { }
    static record StaticNestRecord() { }
    static enum StaticNestEnum { }
}

enum OuterEnum {
    CONSTANT;

    class NestClass { }
    interface NestInterface { }
    enum NestEnum { }
    record NestRecord() { }
    static class StaticNestClass { }
    static interface StaticNestInterface { }
    static record StaticNestRecord() { }
    static enum StaticNestEnum { }
}

public interface OuterInterface {
    class NestClass { }
    interface NestInterface { }
    enum NestEnum { }
    record NestRecord() { }
    static class StaticNestClass { }
    static interface StaticNestInterface { }
    static record StaticNestRecord() { }
    static enum StaticNestEnum { }
}

record OuterRecord(String s) {
    class NestClass { }
    interface NestInterface { }
    enum NestEnum { }
    record NestRecord() { }
    static class StaticNestClass { }
    static interface StaticNestInterface { }
    static record StaticNestRecord() { }
    static enum StaticNestEnum { }
}

で、全部のコンストラクタ舐めて出力。インタフェースはコンストラクタ無いから出ないけどね。

        String[] targets = {"Class", "Interface", "Enum", "Record"};

        record OuterAndNest(String outer, String nest) {
            Class<?> nestClass() {
                try {
                    return Class.forName(nest);
                } catch (ClassNotFoundException e) {
                    throw new RuntimeException(e);
                }
            }
        }
        Arrays.stream(targets)
                .map(outer -> "Outer" + outer)
                .peek(System.out::println)
                .flatMap(outer -> Stream.concat(
                        Arrays.stream(targets).map(nest -> new OuterAndNest(outer, outer + "$Nest" + nest)),
                        Arrays.stream(targets).map(nest -> new OuterAndNest(outer, outer + "$StaticNest" + nest))
                ))
                .forEach(e -> {
                    Class<?> nestClass = e.nestClass();
                    System.out.println("  " + nestClass);
                    Arrays.stream(nestClass.getDeclaredConstructors())
                            .forEach(constructor -> {
                                System.out.println("    " + constructor);
                            });
                });

結果。

OuterClass
  class OuterClass$NestClass
    OuterClass$NestClass(OuterClass)
  interface OuterClass$NestInterface
  class OuterClass$NestEnum
    private OuterClass$NestEnum(java.lang.String,int)
  class OuterClass$NestRecord
    OuterClass$NestRecord()
  class OuterClass$StaticNestClass
    OuterClass$StaticNestClass()
  interface OuterClass$StaticNestInterface
  class OuterClass$StaticNestEnum
    private OuterClass$StaticNestEnum(java.lang.String,int)
  class OuterClass$StaticNestRecord
    OuterClass$StaticNestRecord()
OuterInterface
  class OuterInterface$NestClass
    public OuterInterface$NestClass()
  interface OuterInterface$NestInterface
  class OuterInterface$NestEnum
    private OuterInterface$NestEnum(java.lang.String,int)
  class OuterInterface$NestRecord
    public OuterInterface$NestRecord()
  class OuterInterface$StaticNestClass
    public OuterInterface$StaticNestClass()
  interface OuterInterface$StaticNestInterface
  class OuterInterface$StaticNestEnum
    private OuterInterface$StaticNestEnum(java.lang.String,int)
  class OuterInterface$StaticNestRecord
    public OuterInterface$StaticNestRecord()
OuterEnum
  class OuterEnum$NestClass
    OuterEnum$NestClass(OuterEnum)
  interface OuterEnum$NestInterface
  class OuterEnum$NestEnum
    private OuterEnum$NestEnum(java.lang.String,int)
  class OuterEnum$NestRecord
    OuterEnum$NestRecord()
  class OuterEnum$StaticNestClass
    OuterEnum$StaticNestClass()
  interface OuterEnum$StaticNestInterface
  class OuterEnum$StaticNestEnum
    private OuterEnum$StaticNestEnum(java.lang.String,int)
  class OuterEnum$StaticNestRecord
    OuterEnum$StaticNestRecord()
OuterRecord
  class OuterRecord$NestClass
    OuterRecord$NestClass(OuterRecord)
  interface OuterRecord$NestInterface
  class OuterRecord$NestEnum
    private OuterRecord$NestEnum(java.lang.String,int)
  class OuterRecord$NestRecord
    OuterRecord$NestRecord()
  class OuterRecord$StaticNestClass
    OuterRecord$StaticNestClass()
  interface OuterRecord$StaticNestInterface
  class OuterRecord$StaticNestEnum
    private OuterRecord$StaticNestEnum(java.lang.String,int)
  class OuterRecord$StaticNestRecord
    OuterRecord$StaticNestRecord()

わかりづらいから flatMap の後で適当に filter しとく。

                .filter(e -> Arrays.stream(e.nestClass().getDeclaredConstructors())
                        .anyMatch(constructor -> Arrays.stream(constructor.getParameterTypes())
                                .anyMatch(parameterType -> parameterType.getName().equals(e.outer()))))

結果。

OuterClass
  class OuterClass$NestClass
    OuterClass$NestClass(OuterClass)
OuterInterface
OuterEnum
  class OuterEnum$NestClass
    OuterEnum$NestClass(OuterEnum)
OuterRecord
  class OuterRecord$NestClass
    OuterRecord$NestClass(OuterRecord)

エンクロージングクラスのインスタンスを受けるようにコンストラクタが弄られるのは class の時だけ、と言うことで。

きっかけ

f:id:irof:20211022234826p:plain

IntelliJ IDEAさんが警告してくれなかったのよね。StaticNestInterfaceStaticNestEnumstatic要らないって灰色で、NestClassstaticにできるよって黄色で。 「あってもなくても意味ないのなら灰色になるんじゃないかなぁ、出ないってことは違いあるのかなぁ」って思ったのだけど、黄色にもなってないので。

f:id:irof:20211022235224p:plain

ああ、一律あってもなくても意味ないのに灰色にしてるわけじゃなく、「enumの不要な修飾子」みたいな括りなのか。なるほど。

f:id:irof:20211022235501p:plain

なおinterfaceにネストした場合は全部のstaticが灰色になってくれます。これはinterfaceのメンバに対する不要な修飾子で引っかかってる模様。

満足。(言語仕様とか見に行く気力は残ってない)

IntelliJ IDEAさんは IDEA-266665 でfix済だった。 2021.3 になったら期待してる通りになりそう。

IntelliJ IDEAで複数のクラスをマージする

値オブジェクトなクラスを呼吸をするように作っていると「あれ、これ同じじゃ?」となることがたまにあります。 使用箇所が少なければ手作業でマージしてもいいのですが、多くの箇所で使っているクラスだと大変。 文字列の一括置換でやれなくはないんですが、正規表現力が試されますし、誤置換が怖いです。import文とかあるし、同じ名前を部分的に使っているクラスやメソッドを置換してしまうと困ったことになります。(こないだ事故った。)

そういう時、Inline Super Class(親クラスのインライン化)が使えます。

f:id:irof:20211021121346p:plain

ちょっと長いですが、全体を貼ります。試してみたいかたはどうぞ。

class OrderService {

    OrderNumber 新規注文(OrderDetail orderDetail) {
        // ... 何らかの処理
        OrderNumber orderNumber = new OrderNumber("dummy");

        return orderNumber;
    }

    Order 注文詳細(ReceiptNumber receiptNumber) {
        if (!receiptNumber.isValid()) {
            throw new IllegalArgumentException();
        }

        // ... 何らかの処理
        Order order = new Order(receiptNumber, new OrderDetail());

        return order;
    }

    BillingNumber 請求(OrderNumber orderNumber) {
        // ... 何らかの処理
        BillingNumber billingNumber = orderNumber.toBillingNumber();

        return billingNumber;
    }
}

/** 受付番号 */
class ReceiptNumber {
    String value;

    ReceiptNumber(String value) {
        this.value = value;
    }

    String value() {
        return value;
    }

    boolean isValid() {
        return !value.isBlank();
    }
}

/** 注文番号 */
class OrderNumber {
    String value;

    OrderNumber(String value) {
        this.value = value;
    }

    String value() {
        return value;
    }

    BillingNumber toBillingNumber() {
        // 何らかのルールに従って生成する
        return new BillingNumber();
    }
}

/** 注文 */
record Order(ReceiptNumber receiptNumber, OrderDetail orderDetail) {
}

/** 注文内容 */
class OrderDetail {
}

/** 請求番号 */
class BillingNumber {
}

これの「ReceiptNumber と OrderNumber が同じでよさそう」と気づいたとしましょう。(登場場面もメソッドも違うから違うでよさそうなんだけど、今回はクラスのマージの紹介なので目を瞑ってください。)

実行ステップ

  1. テストが通るのを確認する
  2. 片方でもう片方を継承する
  3. テストが通るのを確認する
  4. Inline Super Class... を実行する
  5. テストが通るのを確認する
  6. 気に入らないメソッドの並び順や変数名を直す
  7. テストが通るのを確認する
  8. コミットする

操作自体は1分かかりません。ファイル数によってはインライン化にそこそこかかるかもしれません。テストの実行時間は努力してください。

初期状態

f:id:irof:20211021143405p:plain

片方でもう片方を継承する

マージなんでどっちがどっちでも構いません。 寄せたい方があればそちらを子クラスにすればいいですが、どちらにせよマージ後に名前変更すればいいだけの話です。 extends して、コンストラクタで super 呼ぶようにして、フィールドを消す。

-class OrderNumber {
+class OrderNumber extends ReceiptNumber {

-    String value;
 
     OrderNumber(String value) {
-        this.value = value;
+        super(value);
     }

-    String value() {
-        return value;
-    }

この作業は手作業になります。なのでテスト必須。

フィールドが private だと使用しているメソッドがコンパイルエラーになりますが、一時的にprotectedにするとか、アクセサメソッドを介するなどで対応できます。

f:id:irof:20211021143447p:plain

Inline Super Class... を実行する

extends の後ろ(今回は ReceiptNumber)にカーソルを持って行って Inline Super Class... を実行します。 私はRefactor ThisにHotkeyを入れてるので冒頭の画像のようになりますが、コンテキストメニューからならこの辺です。(⌥⌘Nなんて覚えてません。なぜか指は勝手に使うけど。)

f:id:irof:20211021123405p:plain

選択ダイアログでは、クラスをマージしたいので親クラスに消えてもらう方を選びます。keepじゃなくremoveの方。

f:id:irof:20211021123201p:plain

結果、以下のような OrderNumber になります。

class OrderNumber {

    String value;

    OrderNumber(String value) {
        this.value = value;
    }

    BillingNumber toBillingNumber() {
        // 何らかのルールに従って生成する
        return new BillingNumber();
    }

    String value() {
        return value;
    }

    boolean isValid() {
        return !value.isBlank();
    }
}

ReceiptNumber にあったメンバが持ってこられてます。privateメソッドやstaticメソッドがあっても持ってきてくれます。

これに加え、ReceiptNumberを使用しているコードもすべてOrderNumberに置き換えられます。型の使用箇所で処理してくれるので、文字列置換とは安心感が段違いです。

super呼び出しがインライン化されるため、子クラスのコンストラクタはsuper呼び出し以外をしないこと。他の処理をいれていると挙動が変わることになります。また、同じメソッドが定義されていた場合(今回だと value() を削除しなかった場合)、警告ダイアログが出てきます。どうとでもなるので、いい感じにしましょう。

f:id:irof:20211021143719p:plain

一気に変わってますが、個別には以下になります。なおメンバのpush downはリファクタリングメニューから個別に実行できます。

f:id:irof:20211021144728p:plain

やりたいのは「使用クラスの変更」なんですが、これを安全に行うのが本稿の内容になります。それなりの規模で根底のドメインを変えると、数百ファイル同時に編集されたりします。手作業では面倒で不安。

これ書いた動機

型とIDEの機能を使えば素早く安定してコードをこねくり回せます。その一例。

値オブジェクトを作ると「クラスがたくさんになり過ぎて困るのでは」みたいなのがありますが、その困ることのひとつに「違うと思ってたけど実は同じだった」というのがあります。この時に時間と精神力を消費して必ず誤る手作業(酷い表現だ)や、祈りの文字列置換を行うのでは気が重いため、作ろうとした時に「違うように見えるけど本当は同じなのでは?」と穿ってみる必要が出てきます。

一塊のものを分けるより、分かれているものをくっつけるほうがよほど簡単です。

もちろん例外はあるけど。一塊のものを分離するのはなかなか機械的にはできません。いくつかできるテクニックはあります(それはまたそのうち……と言って書いてないのどんだけあるだろう)が、いつもできるわけではありません。 一方、分かれているものをくっつけるのはほぼほぼ機械的にできますし、機械的にできるものは今回のようにIDEで一発だったりします。

てことで、また知識が十分でない時は「なんか違うものっぽいなぁ」と感じたらじゃんじゃんクラスを作っていくといいと思います。同じかどうかを判断するのは後からでもできる。「同じと扱っていたものを後から分ける」より、「違うと思っていたものが実は同じだったからくっつける」の方が楽だと思います。

f:id:irof:20211021132551p:plain

ツイートのリンク。まぁなんだ、要はバランスですよ、はい。

おまけ: 簡単で不安定な別解

少々乱暴ですが、以下の方法もあります。

  1. 2つのクラスの内容を一致させる。(コピペ。)
  2. 片方のクラスをファイルごと削除する。(使用箇所があるとか警告されますが無視して実行。コンパイルエラーになる。)
  3. 残った方の名前を削除した方の名前に変換する。(リファクタリング機能を使用する。)

コンパイルエラー状態でのリファクタリング機能は精度が落ちるので推奨はしません。最近はだいぶいい感じに動きますが、安心して使えるとは言い難いです。 この手順では、2番目でファイルを削除しなかったら「ファイル名が一致する」とかで怒られます。

さらに派生で削除する代わりに参照先モジュールに持っていくことでコンパイルエラーを回避するとかもありますが、マルチモジュール構成でなければできませんし、大掛かりになるので他の事故要因も出てきます。どうしても必要な時にって感じですね。

蛇足: recordのとき

recordを使うと継承が使用できないので、素直にはできなくなります。現段階では一旦recordをclassに書き換えて上記の手順を実施、その後recordに戻す……かなぁと思ってます。IntelliJ IDEAでclassとrecordの変換は相互にできるので、これも別に手間じゃないかなって。

あわせてよみたい?

irof.hateblo.jp

ライブコーディング中に使ったIntelliJ IDEAの機能を適当に紹介してます。今回書いたのは意識して使う機能ですが、リンク先で挙げてるのはほんと無意識に使うレベルのもの。