IT勉強会界隈の冬の風物詩といえば、飯テロ勉強会の異名を持つブリ会議 ―― burikaigi ―― だろう。
toyama-eng.connpass.com

そもそも富山で旬の寒ブリを食べようというIT業界人の同好の士が、ブリを食べるために勉強会を催したというのが始まりである。飯テロ勉強会なのは仕方ない。
しかし、勘違いしないで頂きたいのだが、あくまでもIT勉強会なので趣旨を誤解なきよう。講師陣は今回も豪華です。セッションの内容も充実。
例年、北陸にあって先端情報に触れる貴重な機会でもあります。地方勉強会のひとつの形ですね。


プログラマーの修学旅行とも評されるburikaigiなのだが、今年ばかりは新型コロナウイルス感染症 COVID-19 の情勢を見るに、大勢で集まって宴会というわけにはいかない。
しかし、松本魚問屋様の協力によってリモート・ブリしゃぶの目途が立ち、burikaigi 2021 オンライン開催とあいなりました。

matsumoto-uodonya.co.jp

なんと、参加者限定 burikaigi スペシャル商品を用意していただきましたよ。
懇親会参加登録者には特設サイトのURLをお伝えいたします。

もちろん、自前でブリを用意してもらっても大丈夫です。
全国からリモート懇親会でブリを楽しめるイベントとなっています。



さて、ここからはリモート・ブリの紹介です。(まるで通販番組みたいだな🤔)

ブリしゃぶは水揚げ当日に血抜き処理をし、丁寧に仕上げたものを瞬間凍結!
生のブリはどうしても獲れるかどうか不確かで、かつ、保存可能な期間も限られるためブリ会議当日にちょうどおいしく配送というのは非現実的なわけです。
冷凍配送ゆえの安定供給。最近の冷凍技術は凄いですね。冷凍品とあなどるなかれ。試食してみたけども、とても美味しかったですよ!
ただ、あくまでブリしゃぶ用とのことでご承知おきください。刺身では力を十全に出し切れない……。

今回はなんと、ゆずぽん酢、タイの昆布締、ぶりジャーキー、ほたるいか塩麴漬けをセットにした飲兵衛仕様でご提供!

とはいえ、懇親会だけ参加とかはダメだよ。ちゃんと勉強しような!

f:id:Nagise:20201217201311j:plain
burikaigi用の特別セット
f:id:Nagise:20210105204911j:plain
冷蔵庫で解凍
f:id:Nagise:20210105212648j:plain
しゃぶしゃぶ

西暦1年は閏年か?

閏年(うるうどし)の話題。

 Twitterで見かけた話題で「西暦1年は閏年かどうかぱっとわからん人おる?」という些か煽り気味のツイートを見かけたのだけども、反射的に「閏年じゃないに決まってるじゃん」とぱっと答えてしまわないだろうか。本当にそうだろうか? そう単純な話なのだろうか?

 プログラミングを学んでカレンダーを扱うことを学ぶ際に置閏法についても簡単に触れられることがある。置閏法というのは閏年や閏月(太陰暦では1年が13ヵ月になるケースがあり追加の月を閏月と呼ぶ)をどのようなルールで挿入するかという話で、まさにアルゴリズムであるからプログラミングの話題と相性がいい。

置閏法

 現代の西暦の置閏法(ちじゅんほう)は

  1. 西暦を 400 で割り切れる年は閏年
  2. 上記以外で西暦を 100 で割り切れる年は平年
  3. 上記以外で西暦を 4 で割り切れる年は閏年
  4. 上記以外は平年

といった手続きで閏年(つまり2月29日がある)か平年かを判定することができる。

 2000年問題といえば西暦を2桁で扱っていたシステムの桁溢れというのが表の問題だったが、裏の問題として閏年というのもあった。2000年は400で割り切れるから閏年なわけだが、どういうわけか100で割り切れるので平年だと判定してしまうバグが見られた。単に4で割るルールだけにしておけば結果的に2000年には問題が起きなかったのに、中途半端に100年ルールだけ追加しちゃったもんだから2000年2月29日にトラブルを出すことになったシステムがいくらかあったようだ。

 やるなら半端は良くない。

その暦はいつからのものか?

 そう、半端は良くない。

 先に述べた置閏法は現代の西暦の置閏法であるが、この暦をグレゴリオ暦という。2014年にJava8が出るまでの古いカレンダークラスの場合(Java8以降のDate Time API の話は後述)、抽象クラスである java.util.Calendar に対して具象クラス java.util.GregorianCalendar が定義されていた。グレゴリアンの名前の通り、グレゴリオ暦をモデル化したものである。さあこれで、ここまで漠然と「西暦」と呼んでいたものがグレゴリオ暦という名前をもって具体化してきた。このグレゴリオ暦ローマ教皇グレゴリウス13世の名前からきている。グレゴリウス13世によってそれ以前の暦であるユリウス暦の改良が命じられ、1582年10月15日からグレゴリオ暦が行用されるようになった。

 旧暦であるユリウス暦ガイウス・ユリウス・カエサルにより紀元前45年1月1日から実施されたものである。西暦1年を考える場合、当時の暦はユリウス暦であることに注意せねばならない。

 西暦1年当時は閏年だったかどうかぱっとわからん人おる? 僕はぱっとわからなかった。

ユリウス暦の置閏法

 ユリウス暦の置閏法は

  1. 西暦を 4 で割り切れる年は閏年
  2. 上記以外は平年

とシンプルである。なんだ、ユリウス暦だとしても100の倍数の年以外は一緒じゃないか、と安心した人も多いだろう。
ただし、上記は紀元8年以降の話である。

紀元前45年にカエサルがこの暦法を導入した際に閏年は4年に1回と決められたが、直後の紀元前44年にカエサルが暗殺された後、誤って3年に1回ずつ閏日が挿入された。この誤りを修正するため、ローマ皇帝アウグストゥスは、紀元前6年から紀元後7年までの13年間にわたって、3回分(紀元前5年、紀元前1年、紀元4年)の閏年を停止した

ユリウス暦 - Wikipedia

あぶない! 西暦1年というのはユリウス暦の導入直後の混乱期で閏年が正しく運用されていなかったようだ

 なお紀元前45年から紀元8年までの間に、どの年に閏年が置かれていたのかについては諸説ある。1999年にローマ暦とエジプト暦の両方の日付が記載された紀元前24年当時の暦が発見され研究に進展があったとのこと。日本語版Wikipediaには該当論文が示されていないが、英語版Wikipediaの記述からすると ALEXANDER JONES 氏の1999年の論文 CALENDRICA II: DATE EQUATIONS FROM THE REIGN OF AUGUSTUS (PDF)が該当のようである。このカレンダーに基づくC. J. Bennett氏の2003年の研究によれば紀元前44, 41, 38, 35, 32, 29, 26, 23, 20, 17, 14, 11, 8年が3年おきの閏年でAD 4から4年おきの置閏法へと戻ったとのことである。

暦法紀年法

 西暦1年あたりの閏年の混乱を見てきたが、そもそもカエサルの時代に「西暦を4で割る」なんて考え方はされていなかったはずである。何しろ紀元前なので。

 ここで暦法紀年法について確認しておきたい。おおざっぱに言えば紀年法は年を数える方法論で、1年をどのように捉えるかの暦と分離可能なのである。例えば日本では和暦が用いられるが、これは紀年法が和暦で、暦法としてはグレゴリオ暦を用いている。なお、日本が現代の暦になったのは明治5年(西暦1872年)11月の改暦以降のことなので、これより前の時代を扱う場合は暦が連続しないので注意が必要である。このあたりの変遷は国立国会図書館江戸から明治の改暦が読みやすいだろう。


 そもそも西暦自体が525年にローマの神学者ディオニュシウス・エクシグウスによって算出されたものである。

525年、ディオニュシウスはローマ教皇ヨハネス1世の委託を受けてキリスト教の移動祝日である復活祭の暦表(復活祭暦表)を改訂する際に、当時ローマで用いられていたディオクレティアヌス紀元(ローマ皇帝ディオクレティアヌスの即位を紀元とする)に替えて、イエス・キリスト受肉(生誕年)の翌年を元年とする新たな紀元を提案した。これはディオクレティアヌスキリスト教の迫害者であり、その迫害者の名を残す事が疎まれたからである[3]。

西暦 - Wikipedia

 つまり、西暦525年から遡って元年を決めたわけである。なお、ディオニュシウスの推定したキリストの誕生年と、ナザレのイエスの実際の誕生年にはズレがあるとされており、紀元前4年頃に生まれたと考えられている。これはヘロデ大王の治世(紀元前37年 - 紀元前4年)の末期に生まれたという記述による。

 では、西暦525年よりも前の時代の紀年法ディオクレティアヌス紀元で遡ればいいかというと、ディオクレティアヌス紀元の元年も西暦284年までしか遡れない。西暦1年は遠いのである。ディオクレティアヌス紀元以前はローマ建国紀元(Ab urbe condita, AVC/AUC)が用いられていたようである。ローマ建国紀元の元年は、紀元前1世紀のローマ人ウァッロによって推定された、都市国家ローマの建国年(紀元前753年)ということである。これもまた、推定で過去に遡って元年が定められたパターンである。さらに前は執政官ふたりの名前を挙げて表したようだ。(紀年法暦法についてはここで取り上げたもの以外もたくさんあるということは付記しておく)

 さて、ここで何が言いたいかというと「西暦を4で割りきれるなら閏年」というのはたまたまであるということ。神学者ディオニュシウスの推定で定められた西暦の紀元からの数字だとたまたま4で割りきれる年が閏年の年となった。後付けである。その西暦をもとに1582年に定められたグレゴリオ暦が、閏年の調整のためにきりがよい100で割りきれる年や、400で割り切れる年を用いた、という後付け設定によるものだ、という点である。

 グレゴリオ暦の前、ユリウス暦は「西暦を4で割りきれる年を閏年をする」と定められたわけではなかった。ユリウス暦が定めらたのは紀元前のことで、初期には閏年が誤って3年おきに入れられたりその調整で閏年がない期間があったりした。それから500年余りたった頃に後付けで西暦が定められた。そうすると、たまたまユリウス暦閏年が西暦を4で割り切れる年だった。しかし単純に4年1度の閏年では春分の日がだんだんずれてきて西暦1582年にグレゴリオ暦に改められ、閏年の100年ルールと400年ルールが生じた。西暦を100で割る、400で割る、というのは後付けの西暦の切りのいい数字を用いたに過ぎない


 なお、1582年から施行されたグレゴリオ暦暦法を、1582年以前にも適用したものは proleptic Gregorian calendar と呼ばれるようだが、日本語では定訳がなく、先発グレゴリオ暦、遡及グレゴリオ暦、予測的グレゴリオ暦、予期的グレゴリオ暦などと訳されるようだ。

曜日

 400年間のうち閏年は97日あるため、日数は 365 × 400 + 97 = 146,097 日 となる。これは7で割ることができ400年で20,871週間あることになる。グレゴリオ暦は曜日も含めて400年周期の暦と言える。

 曜日の算出にはツェラーの公式が用いられることがあるが、このツェラーの公式を用いた曜日計算も1582年10月15日のグレゴリオ暦への切り替え以前に適用できないことは注意が必要である。計算することはできても、それは実際にその時代に用いられた暦ではないナニカ(先発グレゴリオ暦)である点は理解しておきたい。

 ユリウス暦グレゴリオ暦との間には約10日のズレが生じていた。切り替えに際してこの10日のズレを調整したわけだが、曜日については連続した形で適用された。つまり、ユリウス暦での 1582年10月4日(木曜日)の翌日がグレゴリオ暦 1582年10月15日(金曜日)とされた。

 また、切替えはヨーロッパ各国で一斉に行えたわけではなく、イタリア、スペイン、ポルトガルなどごく少数の国であったという。ローマ教皇による発令だったが、ローマ教皇というのはカトリックのトップであって、プロテスタントやその他のキリスト教派閥にまでその威光が通じたわけではなかったのだろう。その後、徐々に広まっていったようである。

Javaの実装

 1995年の初期のJavaから存在するjava.util.Dateクラスは、当初は日付を表す文字列をパースしたりする機能も有していたのだが、1997年のJava1.1にはこれらの機能は早々に非推奨(Deprecated)になってしまっている。そうした機能はjava.util.Calendarに分離された。java.util.Calendarはカレンダーの抽象クラスで、グレゴリオ暦に相当するのはjava.util.GregorianCalendarである。

 Java8 (2014年)が出た際にはDate and Time APIというものが追加された。(JSR 310: Date and Time API
Java8以降でプログラミングするのであれば概ね新しいDate and Time APIを使いたいところだ。

 さて java.util.GregorianCalendar だが、

歴史的に、グレゴリオ暦を最初に採用した国々では、1582年10月4日(ユリウス歴)のあとに1582年10月15日(グレゴリオ歴)が続きました。 このカレンダはこれを正確にモデル化しています。 グレゴリオ暦への切換え日の前は、GregorianCalendarではユリウス暦を実装しています。 グレゴリオ暦ユリウス暦の唯一の違いはうるう年のルールです。 ユリウス暦は4年ごとにうるう年を指定しますが、グレゴリオ暦では、400で割り切れない世紀の初年をうるう年にしません。

GregorianCalendarは、先発グレゴリオ暦およびユリウス暦を実装します。 すなわち、日付の計算では、現在のルールを無限の過去あるいは未来に向けて適用します。 このため、GregorianCalendarはすべての年について一貫した結果を生成するために使用できます。 ただし、GregorianCalendarを使用して得られた日付は、歴史的に、現代と同様のユリウス暦が採用されたAD 4年3月1日以降の日付だけが正確です。 この日付より前には、うるう年のルールは不規則に適用されており、BC 45年以前にはユリウス暦は存在さえしていませんでした。

GregorianCalendar (Java SE 11 & JDK 11 )

マジか!

Calendar c = new GregorianCalendar(1582, Calendar.OCTOBER, 4);
System.out.print(c.getTime());
c.add(Calendar.DAY_OF_MONTH, 1);
System.out.print(c.getTime());

Thu Oct 04 00:00:00 JST 1582
Fri Oct 15 00:00:00 JST 1582

 1582年10月4日(木)の翌日が1582年10月15日(金)になっている……。
 100年ルールも確認してみよう。1600年は400年ルールが適用なので、1500年と1700年で比較してみよう。

Calendar c = new GregorianCalendar(1500, Calendar.MARCH, 1);
c.add(Calendar.DAY_OF_MONTH, -1);
System.out.println(c.getTime());

c = new GregorianCalendar(1700, Calendar.MARCH, 1);
c.add(Calendar.DAY_OF_MONTH, -1);
System.out.println(c.getTime());

Sat Feb 29 00:00:00 JST 1500
Sun Feb 28 00:00:00 JST 1700

 ユリウス暦である1500年3月1日の前日は2月29日となっている。一方、グレゴリオ暦の1700年は100年ルールが適用されて3月1日の前日は2月28日だ。
 先発グレゴリオ暦のつもりでjava.util.GregorianCalendarを使うと、予期しない動きをするかもしれない。

 なお、純粋に先発グレゴリオ暦として用いたい場合は setGregorianChange(java.util.Date) メソッドでグレゴリオ暦への切り替え日をより過去にすることで対処することができる。

ISO 8601

 ISO 8601 で日付と時刻の表記に関する国際規格が定められている。

 端的に言えばこのISO 8601は先発グレゴリオ暦で、1582年10月15日以前にも適用される。ただし、0000年から1582年の範囲は、事前に通信の送信側と受信側との間での合意がある場合にのみ使うことができるとされている。

 このISO 8601に相当するのが IsoChronology である。Java8 以降の Date and Time API での標準はこのIsoChronologyとなっている。

 java.time.chronoパッケージの解説によればIsoChronologyの他に

が標準で実装が提供されている。これらの暦にまで踏み込むと話が長くなりすぎるので打切り。太陽太陰暦での閏月などについては興味のある人は調べてみると面白いだろう。

結論

 結果的に言えば西暦1年は閏年ではなく平年である。

 しかし、その導出過程は「西暦を4で割り切れないから」ではなく、「ユリウス暦は初期には閏年が誤って3年おきに入れられ、その調整で閏年がない期間があり、西暦1年はその期間に該当するから」というのがより正確な回答であろう。

 なんらかのアルゴリズムを学んだ時に、なぜそのアルゴリズムなのか、その適用範囲はどこまでなのか、現実に人がそのように使っているのか? といった部分にも興味をもって目を向けてみると少し世界がひろがって少し面白い世界が知れるかもしれない。

OSSの通らない自動テストを追いかけた話

備忘録。

MyBatis Migrationsソースコード を落としてきて自動テストを叩いてみたけども通らないテストがあったんですよ。(masterブランチ commit 29945670f2937e3c9f107d9f59e6b03d7e58926e 2020-10-19版)

java.lang.AssertionError:
Expected: (an instance of java.io.FileNotFoundException and exception with message is "/tmp/NoSuchFile.sql (No such file or directory)")
but: exception with message is "/tmp/NoSuchFile.sql (No such file or directory)" message was "\tmp\NoSuchFile.sql (指定されたパスが見つかりません。)"
Stacktrace was: java.io.FileNotFoundException: \tmp\NoSuchFile.sql (指定されたパスが見つかりません。)
at java.io.FileInputStream.open0(Native Method)
at java.io.FileInputStream.open(FileInputStream.java:195)
at java.io.FileInputStream.(FileInputStream.java:138)
at java.io.FileReader.(FileReader.java:72)
at org.apache.ibatis.migration.commands.BaseCommand.copyTemplate(BaseCommand.java:173)
at org.apache.ibatis.migration.commands.BaseCommandTest.testNonexistentFile(BaseCommandTest.java:61)
(後略)

該当のテストコードはこちら

  @Test
  public void testNonexistentFile() throws Exception {
    String srcPath = "/tmp/NoSuchFile.sql";
    expectedException.expect(FileNotFoundException.class);
    expectedException.expectMessage(is(srcPath + " (No such file or directory)"));

    File dest = File.createTempFile("Out", ".sql");
    try {
      BaseCommand.copyTemplate(new File(srcPath), dest, null);
    } finally {
      dest.delete();
    }
  }

expectedException には @Rule がついてて、要するに実行してエラーが出たら、エラーメッセージが想定通りかを検証している自動テストなわけです。
スタックトレースから "/tmp/NoSuchFile.sql (No such file or directory)" が期待されるメッセージだったけど "\tmp\NoSuchFile.sql (指定されたパスが見つかりません。)" というメッセージだったから合致しなかったよ、ということが読み取れます(基本)

試したこと

JUnitの実行時のJavaVMのオプションに -Duser.language=en を設定する。
これはシステム・プロパティの値を設定するもの。
詳しくは java コマンドのリファレンス で -D のところを見てください。
システム・プロパティ "user.language" でデフォルトの言語ロケールを変更することができます。

しかし、これを設定してもテストは通らず。

自動テストに手を加えて、直前でプログラムでシステムプロパティを変更してみます。

System.setProperty("user.language", "en");

しかし、これでも効果なし。なぜ?

原因探索

言語の設定をしてもエラーメッセージが変わらないので FileNotFoundException が作られるところでどういうコードになっているのか探すことに。

at java.io.FileInputStream.open0(Native Method)
at java.io.FileInputStream.open(FileInputStream.java:195)
at java.io.FileInputStream.(FileInputStream.java:138)

トレースを辿ると FileInputStream の open0 メソッドで Native Method になってしまいました。OSに合わせたC言語のネイティブコードになってるわけですね。

でも大丈夫! JavaGPLオープンソースなのでネイティブメソッドもばっちり公開されています。
Javaのコードは元々Mercurialというバージョン管理ツールで管理・公開されていたんですが、世の中のシェアを鑑みて最近Gitに変わりました。(JEP357)


さて、OpenJDKのリポジトリのソースを見てみましょう。
今回のターゲットは共通部は src/java.base/share/native あたりに、OS固有のものは src/java.base/【OS名】/native あたりにあります。

まずは FileInputStream.c から

JNIEXPORT void JNICALL
Java_java_io_FileInputStream_open0(JNIEnv *env, jobject this, jstring path) {
    fileOpen(env, this, path, fis_fd, O_RDONLY);
}

さらに辿って io_util_md.c の fileOpen()

void
fileOpen(JNIEnv *env, jobject this, jstring path, jfieldID fid, int flags)
{
    FD h = winFileHandleOpen(env, path, flags);
    if (h >= 0) {
        jobject fdobj;
        jboolean append;
        fdobj = (*env)->GetObjectField(env, this, fid);
        if (fdobj != NULL) {
            // Set FD
            (*env)->SetLongField(env, fdobj, IO_handle_fdID, h);
            append = (flags & O_APPEND) == 0 ? JNI_FALSE : JNI_TRUE;
            (*env)->SetBooleanField(env, fdobj, IO_append_fdID, append);
        }
    }
}

同ファイル内のwinFileHandleOpen()

FD winFileHandleOpen(JNIEnv *env, jstring path, int flags)
{
/* 中略 */
    if (h == INVALID_HANDLE_VALUE) {
        throwFileNotFoundException(env, path);
        return -1;
    }
    return (jlong) h;
}

さらに io_util.c の throwFileNotFoundException()

void
throwFileNotFoundException(JNIEnv *env, jstring path)
{
    char buf[256];
    size_t n;
    jobject x;
    jstring why = NULL;

    n = getLastErrorString(buf, sizeof(buf));
    if (n > 0) {
        why = JNU_NewStringPlatform(env, buf);
        CHECK_NULL(why);
    }
    x = JNU_NewObjectByName(env,
                            "java/io/FileNotFoundException",
                            "(Ljava/lang/String;Ljava/lang/String;)V",
                            path, why);
    if (x != NULL) {
        (*env)->Throw(env, x);
    }
}

さらに jni_util_md.c の getLastErrorString()

JNIEXPORT size_t JNICALL
getLastErrorString(char *buf, size_t len) {

    DWORD errval;

    if ((errval = GetLastError()) != 0) {
        // DOS error
        size_t n = (size_t)FormatMessage(
                FORMAT_MESSAGE_FROM_SYSTEM|FORMAT_MESSAGE_IGNORE_INSERTS,
                NULL,
                errval,
                0,
                buf,
                (DWORD)len,
                NULL);
        if (n > 3) {
            // Drop final '.', CR, LF
            if (buf[n - 1] == '\n') n--;
            if (buf[n - 1] == '\r') n--;
            if (buf[n - 1] == '.') n--;
            buf[n] = '\0';
        }
        return n;
    }

    // C runtime error that has no corresponding DOS error code
    if (errno == 0 || len < 1) return 0;
    return strerror_s(buf, len, errno);
}

ここで FormatMessage というのが Win32 APIFormatMessage ということのようです。

DWORD FormatMessage(
DWORD dwFlags,
LPCVOID lpSource,
DWORD dwMessageId,
DWORD dwLanguageId,
LPTSTR lpBuffer,
DWORD nSize,
va_list *Arguments
);

というパラメータになっていて dwLanguageId に 0 が渡されていますね。

If you pass in zero, FormatMessage looks for a message for LANGIDs in the following order:

1. Language neutral
2. Thread LANGID, based on the thread's locale value
3. User default LANGID, based on the user's default locale value
4. System default LANGID, based on the system default locale value
5. US English

ここでメッセージが日本語にされていたようです。OSレベルのメッセージだったからJavaシステムプロパティで言語設定を変えても影響されず、日本語のメッセージが表示された、というわけでした。

結論

こんな自動テストは消してしまおう……!

ファイルパスの違いは JUnit の OS指定の @EnabledOnOs アノテーションで逃げることができるのですが、今回はメッセージの部分まで含んでいるのでこのアノテーションでの対処では不十分です。

OS に依存するメッセージを固定でテストするというのは過ぎた制約で、テストとしてはおよそやりすぎの類のものです。
テストコードに手を加えずに通したいなら言語設定が英語のmac使うといいんじゃないかな。

Spring boot で作るAPIと React のビルドを共存させたい

Spring boot では Webシステムをmain起動のスタンドアロンJavaプログラムのように起動して試せる点が便利である。ところでこうしたサーバーサイドのWeb APIを作るようなケースで、Reactのようなコンパイルを伴うWebクライアントと連携して動作するようなものを開発するとき、どのようなプロジェクトのディレクトリ構成でどのようなビルド、どのような実行をすると便利にやれるのだろうか?

IDEの有償プラグインがこうした開発を統合してくれるのかもしれないが(試用して回ったわけではないのでよくわからない)ここではギョームで普通に用いられているEclpise IDEVisual Studio Codeの組み合わせで考えてみたい。(本稿ではIDEは主題ではないので、任意のIDEで検討してもらいたい)

Spring の フォルダ

SpringのQuick Start に従い、デモプロジェクトを作成し、Eclipse側でMavenプロジェクトとしてインポートすると以下のような構成になっている。

f:id:Nagise:20200627145159p:plain
Spring boot の構成

ここで、DemoApplicationを実行するとSpring bootが内臓のTomcatを起動させ、 src/main/webapp 以下(図のオレンジの矢印の部分)が http://localhost:8080/ 以下のパスでアクセスできるようになる。
加えて、@GetMapping("/hello") のアノテーション定義に則って http://localhost:8080/hello にアクセスすると、そのアノテーションがついているhelloメソッドを呼び出し、その戻り値をhttpのレスポンスとして返すように動く。

React の フォルダ

create-react-app を使ってシンプルなReactのサンプルを作成すると、以下のような構成になっている。

f:id:Nagise:20200627145306p:plain
React の構成

npm run build でコンパイルすると、html や JavaScript が動く形で build ディレクトリに作成される(図のオレンジの矢印部分)。Webアプリケーションとしてサーバーに配置するのはこのbuild部分ということになる。

双方の都合

サーバーサイドのWeb APIと、コンパイルして生成されるWeb クライアントを複合して動かす場合に、上記のような2系統のビルドをどうにか両立させないといけない。

最終的にTomcatのようなWebサーバーに配備する場合であれば、ビルドスクリプトによってReactのビルドを動かし、生成物を Spring boot 側の webapp 以下の配備予定パスに複製、そののち、 Spring boot 側をwarファイルにパッケージングしてwarファイルをWebサーバーに配備、という手順を踏むことになるのだろう。しかし、開発段階からこれをやるのは面倒だし、開発時にはクライアント側もサーバー側も双方ともいじりながら開発することもあるから、両開発環境でビルドがしたい。(逆に、完全に分業がされているならば、この2系統ビルドで頭を悩まさなくていい)

この2系統ビルドとその実行を便利にしたい、というのが今回のテーマである。

方法はいろいろあるのだろうが、今回は Spring boot をハックして Tomcat の Resources Component機能を利用してデバッグ環境のTomcatがSpring boot の src/main/webapp ディレクトリと、Reactのbuildディレクトリの双方を参照するように設定してみたい。

Tomcatによるリソースの読み込み

あまり知られていないかもしれないが、Tomcatにはファイルリソースを読み込むにあたって、ファイルを読み替えることができる。

日本語で解説してくれている記事は少ないが、 webapps配下でないディレクトリにリソースを置く - DukeLab では例が挙げられている。
Tomcatがリソースを読み込む際に PreResources の設定があれば、先にそちらを読みに行き、そこにリソースがなければ通常のwebappフォルダ、そこにもなければ PostResources の設定の場所を読みに行く。これによって、設定ファイルなどをwarファイルの外に分離したりすることができる。

TomcatThe Resources Component というドキュメントに詳細が記載されている。
この機能を用いる際には注意点がある。Javaプログラム中で設定ファイル等にアクセスする際には java.io.Fileクラスなどを用いてはいけない。 javax.servlet.ServletContext の getResource() などのメソッドを用いてファイルリソースを取得することを徹底しなくてはならない。HTMLなどの静的ファイルにだけ用いる分にはこのあたりはあまり気にする必要はない。

この Resources Component機能の設定は古典的には Tomcat本体のserver.xmlを編集することになる。このserver.xmlは、Tomcatがwarファイルをどのように配備するかといった設定を記載するファイル。しかし、warファイル側でこのデフォルト設定みたいなのを書いておきたいみたいな要件があり、 warファイルの /META-INF/context.xml を用意しておくと、配備時にこの設定が生かされるようになった。

これを Spring boot の内蔵Tomcatに対してどう設定するかという話なのだが、/META-INF/context.xml を用意しても内蔵Tomcatには効かない。なにか方法はないかと探していたら
SpringBootで組み込みTomcatの設定 という記事が目についた。Spring boot では TomcatServletWebServerFactory内蔵Tomcatの設定を行えるらしい

しかし、APIドキュメントを探してみてもResources Componentの設定は行えなさそうだ。さて、どうする……?

WebResourceRoot

Spring boot の TomcatServletWebServerFactory のソースコードを読み込んでいると、org.apache.catalina.WebResourceRoot というTomcatのクラスの createWebResourceSet というメソッドを呼んでいる箇所が気になった。

	URL url = new URL(resource);
	String path = "/META-INF/resources";
	this.context.getResources().createWebResourceSet(ResourceSetType.RESOURCE_JAR, "/", url, path);

前後を読むとこれが直接の該当機能というわけではないのだが、この createWebResourceSet メソッドこそが求める Tomcat の Resources Component 機能であるようだ。WebResourceRoot は interface なので Tomcat のソースを辿り実装クラス StandardRoot を見つける。このソースを見るとこれが探していた機能であることを確信する。これを Spring boot で Tomcat を設定する TomcatServletWebServerFactory でうまく呼び出せれば……?

import org.apache.catalina.Container;
import org.apache.catalina.Host;
import org.apache.catalina.WebResourceRoot;
import org.apache.catalina.WebResourceRoot.ResourceSetType;
import org.apache.catalina.core.StandardContext;
import org.apache.catalina.startup.Tomcat;
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
import org.springframework.boot.web.embedded.tomcat.TomcatWebServer;
import org.springframework.boot.web.server.WebServer;
import org.springframework.boot.web.servlet.ServletContextInitializer;
import org.springframework.boot.web.servlet.server.ServletWebServerFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class TomcatConfigration {
    @Bean
    public ServletWebServerFactory serverSettings() {
        TomcatServletWebServerFactory factory = new MyTomcatServletWebServerFactory();
        return factory;
    }

    static class MyTomcatServletWebServerFactory extends TomcatServletWebServerFactory {
    	@Override
    	public WebServer getWebServer(ServletContextInitializer... initializers) {
    		TomcatWebServer ret = (TomcatWebServer) super.getWebServer(initializers);

    		Tomcat tomcat = ret.getTomcat();
    		Host host = tomcat.getHost();
    		Container[] findChildren = host.findChildren();
    		StandardContext context = (StandardContext) findChildren[0];
    		WebResourceRoot webResourceRoot = context.getResources();
    		webResourceRoot.createWebResourceSet(ResourceSetType.PRE,
    				"/", "C:\\work\\react\\demo\\react-demo\\build", null,  "/");
    		return ret;
    	}
    }
}

単純にWebResourceRootを得て素直にcreateWebResourceSet()を呼び出せなかったため工夫する必要があった。

TomcatServletWebServerFactory を継承したクラスをつくり、getWebServer()をオーバーライドしてsuper.getWebServer(initializers)で作成した TomcatWebServer オブジェクトから WebResourceRoot を引っ張り出し、createWebResourceSet() に react側のbuildディレクトリを指定するようにしてみた。

f:id:Nagise:20200627151041p:plain
Spring boot と React の共存


このように、Spring boot の動的なAPIと、React が Spring boot の内臓Tomcatによって同時させることができた。
URL がともに localhost:8080 となっている点に注目いただきたい。

まとめ

  • Spring boot の内蔵 Tomcat に設定を加えることで、外部のファイルリソースを組み合わせることができた
  • Reactのような別系統のビルドが必要な環境と組み合わせて開発する際に便利

よりよい工夫などあれば、トラックバックして頂けると嬉しい。

追記

Reactに限って言えば、React側のサーバーにproxyを指定してやることで、Spring boot側のAPIを同一ドメインで呼び出せるようにすることができるようだ。

React開発時には、APIサーバーとReactアプリサーバーを別にして、プロキシを使うというベスト・プラクティス

本来静的コンテンツであるところの HTML + JavaScript の動作を確認するためにWebサーバーを立てているわけだが(npm startで起動させているアレである)そこのProxyを通して本来の動的WebサーバーであるところのTomcatを呼び出している。僕がサーバーサイドエンジニアだからか、この方式は少し不思議な構成という気がする。


Vue.js でも同様のアプローチをとっているようだ。

[Vue]devServerでフロント開発中、別ポートで起動しているバックサーバーにリクエストを送る方法 - Qiita


本記事のアプローチはこれらとは逆のような感じになるが、React や Vue.js に限らず、汎用に用いることができる方式であるし、なんとなれば、React と Vue.js の混在にでも対応できる。(そんなことをしたいわけでもないが)

また、proxyがどのような通信方式をサポートしているかという点にも不安があって、例えば WebSocket を持ちいたAPIサーバーなどに対応できているのか?などなど、サーバーアプリ屋としてはサーバーをメインに据えたい気持ちがいろいろとある。

しかし、視点を変えて、分業体制とかでクライアントサイドを主体としている開発者側からはproxy方式の方が便利なのかもしれない。

セミコロンレスJava はチューリング完全か?

セミコロンレスJava (Semicolonless Java)というのは Javaセミコロンを使わずにコードを書くというチャレンジである。ネタであり言語の構文を駆使して行うプログラミングパズルの類の遊びである。(ときどき本気にしてその有用性について聞いてくる人がいるので、この冗談がどういう冗談でどう面白いのかの解説のような無粋なことを書いている)

ところで、セミコロンを使わずともJavaチューリング完全なのであろうか?

証明方法

チューリング完全であるという証明は、すでにチューリング完全であることが知られている体系を該当環境で実装することで行うのが簡単な方法論である。

Brainf*ck といったチューリング完全な簡易プログラミング言語を実装するとか、チューリング完全であることが知られているライフゲームを実装するとかが考えられる。

今回は、ライフゲームの1次元版とも言える、1次元セルオートマトンを作成することでこの証明を行う。

Elementary cellular automaton (基本セルオートマトン)は 0 と 1 の連続したセルからなり、自身とその両隣、つまり3bitの組み合わせから、次世代のセルの値が定まる。

f:id:Nagise:20200527201721g:plain
基本セルオートマトン

この3bitの組み合わせ8種類が、それぞれ次世代に0になるか1になるかの組み合わせが2^8通り考えられ、この組み合わせを0~255の値で表し、ルール110 などと呼ぶ。

ルール110チューリング完全になることが知られている。

実装

Githubにも上げてあるが、コードは短いので全文を記載する。


public class ElementaryCellularAutomaton {

	public static void main(String[] args) throws Exception {
		synchronized (new ElementaryCellularAutomaton(new Rule(110), "0000000000000001", 20)) {}
	}

	/**
	 * Run Elementary cellular automaton.
	 *
	 * @param rule
	 * @param states initial states. made of "0" and "1"
	 * @param generation
	 */
	public ElementaryCellularAutomaton(Rule rule, String states, int generation) throws Exception {
		synchronized (new Output(states)){}

		while (--generation  > 0) {
			synchronized (new Output(states = new Next(states, rule).peek().toString())){}
		}
	}

	/**
	 * Calculate the next generation.
	 */
	static class Next extends java.util.Stack {
		/**
		 * Calculate the next generation.
		 * @param states
		 * @param rule
		 * @return String : next generation
		 */
		public Next(String states, Rule rule) {
			synchronized(push(new Next(states, (char[])rule.peek(), 0, new StringBuffer()).peek())) {}
		}

		private Next(String states, char[] map, int x, StringBuffer sb) {
			synchronized (sb.append(map[(states.charAt(states.length()-1)-'0')*4
					+(states.charAt(0)-'0')*2
					+states.charAt(1)-'0'])) {}
			while (x<states.length()-2) {
				synchronized (sb.append(map[(states.charAt(x) - '0')*4
						+(states.charAt(x+1) - '0')*2
						+states.charAt(x++ +2) - '0'])) {}
			}
			synchronized (push(sb.append(map[(states.charAt(states.length()-2)-'0')*4
					+(states.charAt(states.length()-1)-'0')*2
					+states.charAt(0)-'0']))) {}
		}
	}

	/**
	 * https://en.wikipedia.org/wiki/Elementary_cellular_automaton#The_numbering_system
	 */
	static class Rule extends java.util.Stack {
		/**
		 * create Rule Object.
		 * @param ruleNo the rule number of the automaton.
		 */
		public Rule(int ruleNo) {
			synchronized(push(new Rule(110, 0, new char[8]).peek())) {}
		}

		private Rule(int ruleNo, int i, char[] map) {
			while (i<8) {
				switch (map[i++] = (char)('0' + (ruleNo & 1))) {}
				switch (ruleNo >>= 1) {}
			}
			synchronized (push(map)) {}
		}
	}

	/**
	 * System.out.println() utility
	 */
	static class Output {
		/**
		 * Output message to System.out
		 * @param message message
		 */
		public Output(String message) throws Exception {
			if (null == java.io.PrintStream.class
					.getMethod("println", new Class[] {String.class})
					.invoke(System.out, new Object[] {message})){}
		}
	}
}

動かし方

コンパイルしてmainを実行するだけ。

パラメータはハードコーディングしてしまっているので、いじりたい場合は以下の部分の値を変更してください。

synchronized (new ElementaryCellularAutomaton(new Rule(110), "0000000000000001", 20)) {}

new Rule(110) は先に解説した基本セルオートマトンのルールの値である。

"0000000000000001" は基本セルオートマトンの初期値。0と1からなる文字列で指定する。とりあえず16文字にしているが、この文字数は多くすることが可能。

20 は出力する世代数。大きな値にすればそのぶん先の世代まで出力してくれる。

技法

本コードは Java 1.1 相当のコードとなっている。一般にセミコロンレスJavaは古いJavaほど構文の制約が多く、難易度が高いとされる。Java1.0での Hello world は未解決問題となっており、Java1.1が事実上の最難関とされる。

セミコロンレスJavaの構造化の手法は過去記事 セミコロンレスJavaの構造化&オブジェクト指向 - プログラマーの脳みそ が詳しい。
セミコロンレスJavaではreturn文が書けないため、戻り値のあるメソッドは定義することができない。そして、戻り値のないメソッドは呼び出しが式にできないためセミコロンなしで呼びだせない。(リフレクションを用いることで回避することができるが、記述が煩わしい)

そこで、java.util.Stackを継承したclassをnewすることでコンストラクタをメソッド呼び出しの代替とし、peek()で戻り値となる値を読みだすという手法で構造化を行う。

Java5 以降であれば、for-each構文でローカル変数宣言を行うことが可能なのだが、Java1.1ではその手法が用いれないためコンストラクタの引数を用いて変数宣言の代用としている。

結論

Java 1.1 はセミコロンを用いずともチューリング完全であることが示された。

Javaのクラス名の形式まとめ

Java言語を扱っていると何通りかのクラス名の表記法を見ることがある

nagise.sample.Hoge.Piyo
nagise.sample.Hoge$Piyo
nagise/sample/Hoge$Piyo
[Lnagise.sample.Hoge.Piyo

これらの違いは何なのか。
Javaのクラス名関連の専門用語を調べ直してみた。

用語 日本語 概要
Identifier 識別子 識別に用いるもの全般を指した抽象度の高い表現
Class Name クラス名 一般に言うクラス名。比較的曖昧な表現。文脈によってはInterfaceも含む。ネストしたクラス、内部クラスの場合には外側のクラスを含めたり含めなかったりする。
Type Name 型名 ここでいう型はInterface / Classを区別しない。参照型に限定した表現でプリミティブ型を含まない
Simple Type Name 単純型名 パッケージ名を含まない型名。Qualified Type Nameと対比して使う表現
Qualified Type Name 限定型名 パッケージ名を含む型名。Simple Type Nameと対比して使う表現
Fully Qualified Name 完全限定名 パッケージ名を含む型名。こちらは参照型に限定せずプリミティブ型も含めての型名として用いられている
Canonical Name 正規名 通常はFully Qualified Nameに同じ。内部クラスなどで同じクラスを表す別Fully Qualified Nameが生じるケースが有って、そうした場合に正規化されたものになる
Binary Name バイナリ名 概ね正規名と同じ。内部クラスでは外側のクラスと$で結合したものとなる
Binary Name(class file) バイナリ名 クラスファイルフォーマット中のバイナリ名はThe Java® Language Specificationのバイナリ名のピリオドをスラッシュに置き換えたもの

Simple Type Name と Qualified Type Name

The Java® Language Specificationの Chapter 6. Names では識別子について書かれている。この中の 6.5.5. Meaning of Type Names では Simple Type Name と Qualified Type Name という用語が出てくる。

これはクラス名だけの部分をさして "Simple Type Name" 単純型名と呼び、パッケージ名つきのものを "Qualified Type Name" 限定型名と呼んでいる。これはそう難しくはない。

Fully Qualified Name 完全限定名(FQN)

同じく Chapter 6. Names6.7. Fully Qualified Names and Canonical Names では Fully Qualified Names という用語が出てくる。Fully Qualified Names は略してFQNとも呼ばれる。

・The fully qualified name of a primitive type is the keyword for that primitive type, namely byte, short, char, int, long, float, double, or boolean.

6.7. Fully Qualified Names and Canonical Names

・プリミティブ型の完全限定名はプリミティブ型のキーワード名と同じくbyte, short, char, int, long, float, double, boolean である

"Fully Qualified Name" という用語にはプリミティブ型も含まれるということだ。また、詳しくは取り上げないがパッケージ名のFQNについても書かれているのでFQNにはパッケージも含むことになるだろう。
トップレベルのクラスは「パッケージ名.クラス名」となる。
そしてメンバークラス(これはネストしたクラスと内部クラスをまとめた概念)の場合は「外部クラスFQN.メンバークラス名」となる。

nagise.sample.Hoge.Piyo

は"Fully Qualified Name" 完全限定名か!?

Canonical Name 正規名

6.7. Fully Qualified Names and Canonical Names には "Fully Qualified Name" 完全限定名 と "Canonical Name" 正規名が出てくる。正規名は完全限定名を正規化したものとなる。

package nagise.sample;

public class Hoge {
    public class Inner {}
}

class Piyo extends Hoge {}

といったように、内部クラスInnerを持つHoge型を継承したPiyo型があったとする。このとき

nagise.sample.Hoge.Inner

nagise.sample.Piyo.Inner

は同じ型を指している。同じ型に対して複数の完全限定名があり得るということだ。これを正規化したものが "Canonical Name" 正規名と言うことになる。

nagise.sample.Hoge.Piyo

のようなクラス名はプログラムから出力されたものがであることがほとんどだろう。完全限定名ではなく正規名である可能性が高い。

Binary Name バイナリ名

13.1. The Form of a Binary にバイナリ名の規定がある。

・The binary name of a top level type (§7.6) is its canonical name (§6.7).

・The binary name of a member type (§8.5, §9.5) consists of the binary name of its immediately enclosing type, followed by $, followed by the simple name of the member.

・The binary name of a local class (§14.3) consists of the binary name of its immediately enclosing type, followed by $, followed by a non-empty sequence of digits, followed by the simple name of the local class.

・The binary name of an anonymous class (§15.9.5) consists of the binary name of its immediately enclosing type, followed by $, followed by a non-empty sequence of digits.

13.1. The Form of a Binary

・トップレベル型(§7.6)のバイナリ名は正規名(§6.7)である
・メンバー型(§8.5, §9.5)のバイナリ名はすぐ外側のクラスのバイナリ名に$と単純名を繋げたものである
・ローカルクラスのバイナリ名はすぐ外側のクラスのバイナリ名に$とシーケンス番号と単純名を繋げたものである
・無名クラスのバイナリ名はすぐ外側のクラスのバイナリ名にシーケンス番号を繋げたものである

このバイナリ名はclassファイル名となって現れる。

nagise.sample.Hoge$Piyo

というわけでこれはバイナリ名。

Binary Name バイナリ名 JavaVM版

Java言語仕様とJavaVM仕様でバイナリ名にずれがある。

For historical reasons, the syntax of binary names that appear in class file structures differs from the syntax of binary names documented in JLS §13.1. In this internal form, the ASCII periods (.) that normally separate the identifiers which make up the binary name are replaced by ASCII forward slashes (/). The identifiers themselves must be unqualified names (§4.2.2).

4.2.1. Binary Class and Interface Names

歴史的な理由により、classファイル構造に表示されるバイナリ名の構文は、JLS§13.1に記載されているバイナリ名の構文とは異なります。この内部形式では.、通常、バイナリ名を構成する識別子を区切るASCIIのピリオド(.)は、ASCIIのフォワードスラッシュ(/)に置き換えられます。識別子自体は修飾されていない名前でなければなりません(§4.2.2)。

「歴史的な理由」についてはわからなかったが、Java1.0の時代から続く仕様なので、1995年以前のJavaの開発時に端を発する話なのだろうと思う。

nagise/sample/Hoge$Piyo

ということで、これはJavaVMでのバイナリ名。

java.lang.Class クラスの各種メソッド

java.lang.ClassクラスJavaのクラスを表すクラスである。ClassクラスはオブジェクトのインスタンスからObject#getClass()メソッドで取得することができる。また、クラス名.class で得ることもできる。

そのClassクラスにはいくつかクラス名を取るメソッドが存在する。

以下、サンプルのためのクラス定義はだいたい以下のようなイメージで記載している。

package nagise.sample;

public class Hoge {
  /** 内部クラス */
  public class Inner {}
  /** ネストしたクラス */
  public static class Piyo {
    /** 二重にネストしたクラス */
    public static class Foo {}
  }

  public static void main(String[] args) {
    // ローカルクラス
    class Local {}
  }
  static void xxx() {
    // 別メソッドに定義されたローカルクラス
    class LocalX {}
  }
}

getName() と getTypeName() はほとんど同じ。内部実装的にgetTypeName()はgetName()を呼んでいる。ただし、配列の場合の挙動は異なりgetName()の場合は

[Lnagise.sample.Hoge.Piyo

という出力になる。これがgetTypeName()の場合は

nagise.sample.Hoge$Piyo[]

となる。このふたつの違いは配列の場合だけ。

では、それ以外の場合を見ていこう。

対象となるclass getSimpleName() getName() getCanonicalName()
nagise.sample.Hoge Hoge nagise.sample.Hoge nagise.sample.Hoge
interface IHoge型 IHoge nagise.sample.IHoge nagise.sample.IHoge
Hoge型配列 Hoge[] [Lnagise.sample.Hoge nagise.sample.Hoge[]
Hoge型配列の配列 Hoge[][] [[Lnagise.sample.Hoge nagise.sample.Hoge[][]
Hoge型の内部クラスInner型 Inner nagise.sample.Hoge$Inner nagise.sample.Hoge.Inner
Hoge型のネストしたクラスPiyo型 Piyo nagise.sample.Hoge$Piyo nagise.sample.Hoge.Piyo
Piyo型のネストしたクラスFoo型 Foo nagise.sample.Hoge$Piyo$Foo nagise.sample.Hoge.Piyo.Foo
ローカルクラスLocal型 Local nagise.sample.Hoge$1Local null
別メソッドのローカルクラスLocalX型 LocalX nagise.sample.Hoge$2LocalX null
無名クラス 空文字 nagise.sample.Hoge$1 null
Integer.TYPE int int int

先にバイナリ名のところに記載したルールに準じている。

ローカルクラスの場合は、シーケンス番号は定義されるメソッドが異なる場合にシーケンス番号が異なることが確認できた。無名クラスも同様。
ローカルクラスや無名クラスではgetCanonicalName()では定義がとれずにnullが帰ることもポイント。javadocには

存在する場合は基本となるクラスの正規名。そうでない場合はnull。

https://docs.oracle.com/javase/jp/11/docs/api/java.base/java/lang/Class.html#getTypeName()

と書かれている。まさに存在しない場合が該当ケースということだろう。

ラムダ式については挙動の根拠になる言語仕様はわからなかった。スクロールしてしまって邪魔なので分離して記載。

対象となるclass getSimpleName() getName() getCanonicalName()
ラムダ式 Hoge$$Lambda$1/0x0000000800062040 nagise.sample.Hoge$$Lambda$1/0x0000000800062040 nagise.sample.Hoge$$Lambda$1/0x0000000800062040

ラムダ式は無名クラスの単なる糖衣構文ではない。生成されるバイトコードが異なる。状態をもつラムダ式とそうではないものでも生成されるバイトコードは変わってくる。本稿ではラムダ式については深入りしない。参考文献として Lambda 式に invokedynamic を使うのかもしれない話 - 映画は中劇 を挙げるにとどめておく。

Enumeration を for-each ループする方法

Twitterで @RayStark77 氏に教えてもらった方法

var v = new java.util.Vector<String>();
java.util.Enumeration<String> e = v.elements();
for (String s : (Iterable<String>)e::asIterator) {
    // ...
}

Enumeration#asIterator() がJava9からのメソッドなので Java9 以降で利用可能。


構文の解説

for (String s : (Iterable<String>)e::asIterator) 

この部分、構文的には少々難しい。

まず拡張for構文。for-eachとも呼ばれる。Java言語仕様としては
14.14.2. The enhanced for statement
を参照されたし。コロン(:)の左側にループ内で用いる変数宣言、右側はjava.lang.Iterableか、配列である必要がある。
ここで

(Iterable<String>)e::asIterator

がどう解釈されるかだが、メソッド参照 e::asIterator が関数型インターフェース Iterable である、というのが概要だ。

まず関数型インターフェースとは何か?について具体例はひしだまさんのサイトに任せるとして、大雑把には未実装の抽象メソッドが1つだけのinterfaceと思えばよい。Iterableはこの条件を満たしていて

Iterator iterator()

Iterable (Java SE 11 & JDK 11 )

というメソッドがその抽象メソッドだ。紛らわしいがIterableのiteratorメソッドがIterator型を返す。

ここで、メソッド参照 e::asIterator はIterator型を返すメソッドだから、関数型インターフェースIterableとなることができて、拡張for文のループ対象となることができる、という仕組み。

メソッド参照が Iterator<String> iterator() と合致するかのコンパイル時動作は15.13.1. Compile-Time Declaration of a Method Referenceに詳細が記載されている。今回のケースだと e::asIterator は Enumeration のインスタンスメソッドから asIterator を探し、 Iterator<String> iterator() と合致する引数・戻り値が適合するものがあるかを調べられる。asIterator() は e、ここでは Enumeration<String> のメソッドだから

  • 引数はなし : 合致
  • 戻り値は Iterator<String> : 合致

ということで、無事合致することができる。

歴史的経緯

java.util.Enumerationは最初期のJava1.0 (1995年)から存在するinterfaceなのだが、Java1.2 (1997年)にコレクションフレームワークが導入された際にその役割を
java.util.Iterator に譲った。deprecated がつく非推奨ではないものの、

新しい実装では、Enumerationに優先してIteratorを使用することを検討する必要があります。

https://docs.oracle.com/javase/jp/11/docs/api/java.base/java/util/Enumeration.html

と記されている通り、新規利用は推奨されていない。なので、バージョンの移行のタイミングなどもあるかもしれないが2000年以降では新規にEnumerationが用いられたコードが書かれることは稀で次第に滅びていくハズ……であったがシェアの高いライブラリに残存しているがために現代でも遭遇してしまう。代表例としてはServletHttpServletRequest.html#getHeaderNames() などで、これはServletの設計がJava1.1時代に行われたことに依る。

Java ServletAPIというのは、当時のSun Microsystemsが定めているが、これはServletを動かす際のAPIであって、サーバーとしての実装は別に各サードパーティーが作って売っていた。Servlet APIに則って作られたプログラムは、どのServletコンテナでも動く。(もっとも各社それぞれ独自機能を提供していたため、それらに依存すれば単純に載せ替えができないが)

この状況はつまり、Servlet API の変更が極めて行いにくい状況を産み出し、20年前の遺恨が現代でも残される状況となった。

なぜキャストを書かないといけないのか?

for (String s : (Iterable<String>)e::asIterator) 

for (String s : e::asIterator) 

と書けないのはなぜなのか?

これは言語仕様には明示されていて

It is a compile-time error if a method reference expression occurs in a program in someplace other than an assignment context (§5.2), an invocation context (§5.3), or a casting context (§5.5).

15.13. Method Reference Expressions

代入、メソッドやコンストラクタの呼び出し、キャスト以外に現れたメソッド参照はコンパイルエラーとなります

このように明示的に規定されている以上、拡張for文には直接書くことができない。

拡張for文

拡張for文が導入されたのはJava5 (2004年)で、同時に導入された機能としてはジェネリクスがある。

再掲になるが拡張for文 14.14.2. The enhanced for statement はコロン(:)の右側はjava.lang.Iterableか、配列である必要がある。

配列をjava.lang.Iterableとしてしまえば良かったのだが、プリミティブ型の配列の問題があって int[] を Iterable<int> とできなかったのだろう。とはいえ、拡張for文はIterableか配列であることが決まっているのだから、型の推論はできるのではないか?と考えられるところだが、OpenJDKコミッタ筋からの情報によると、「Brianが昔できるけどやらないって言ってましたね」とのこと。このBrianというのはJava言語の開発責任者の Brian Goetz氏のことで、この推論をやることになんらかの躊躇があったのだろう。