入門 Java言語仕様を読もう! with Java Puzzlers

2025年6月7日(土) JJUG CCC 2025 Spring お疲れさまでした。登録者は1000人を超えたようで、会場もなかなかの人手だったかと思います。

タイムテーブルを見るとよく分かりますが、8トラックも並行しているのでどのセッションを見ようか悩ましかったのではないでしょうか。

今回の私のセッションは「入門 Java言語仕様を読もう! with Java Puzzlers」という題目で、Java言語仕様を読むための基礎の話と、Java PuzzlersというJava言語仕様にまつわるクイズの出題、関連するJava言語仕様の解説、という内容でした。

JJUGのセッションはどうも雰囲気が硬くなりがちなので、聞き手にゆとりさんを迎えて対話形式で進めました。資料部分は事前に共有していましたが、問題部分は見せていないので、新鮮なリアクションをいただけて良かったと思います。

Java言語仕様を1次資料として参照してくれる人が増えると嬉しいな。

書籍 Java Puzzlers (2005年 ジョシュア・ブロック, ニール・ガフター)は絶版ですが、運が良ければ中古で安く入手できるかも? 今回はこの書籍からの改題したものを中心に出題しました。

枕の話の部分だけまとめた資料を別途公開します。問題とその解説はスライドでは断片的なのでこのblogでやります。

Q1 拡張forでEnumerationのasIterator()を回そうとしたらどうなるか?

Q1 のポイントはfor文です。 e::asIterator とメソッド参照でfor文を回そうとしています。

import java.util.*;

public class Q01 {
 public static void main(String[] args) {
   Enumeration<String> e = Collections.enumeration(
       List.of("Hello", "JJUG", "2025"));
   for (String s : e::asIterator) {
     System.out.print(s);
   }
 }
}

選択肢は

  1. HelloJJUG2025 が表示される
  2. コンパイルエラー
  3. その他

Q1の解説

正解は 2.コンパイルエラー。メソッド参照の登場できる場所には制約があります。

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

これは過去のblog Enumeration を for-each ループする方法 が元ネタ。

言語仕様のバッカス・ナウア記法(BNF : Backus–Naur form)の解説をやるために、拡張for文がらみの問題をチョイスしました。

拡張for文のBNF§14.14.2. The enhanced for statement に書かれていて、右辺はExpressionとなっています。ここから追いかけて行って「メソッド参照がない」ことを確信するのはちょっと大変かもしれない。

blog側に解説していますが、キャストを書けばコンパイルが通ります。

Q2 突然の URL

import java.util.*;
import java.net.http.*;

public class Q02 {
   public static void main(String[] args) throws Exception {
       HttpClient httpClient = HttpClient.newHttpClient();
       URI https = URI.create("https://www.google.com");
       HttpRequest request = HttpRequest.newBuilder(https).build();
       HttpResponse<String> response = httpClient.send(
              request, HttpResponse.BodyHandlers.ofString());
       https://www.google.com
       System.out.println(response.body());
   }
}
  1. 正常終了
  2. コンパイルエラー
  3. その他

コード中に現れる https:// の文字……。果たして。また、httpsという名前の変数も宣言しているのですが、影響するのか否か?

Q2 解説

これは一部界隈では有名なネタで……(なんの界隈かと言われるとアレなんですが)Javaの構文的に https: 部分がラベルで、//より後ろが1行コメントとなって合法になります。正常終了……と言いたいところですが、セッション中はネットワーク不調で実行時エラーが出るというはハプニングが。URLにちなんでネットワーク使うコードになんてしなければ良かった……。

これはJava Puzzlers より パズル22 を改題。書籍Java Puzzlersは2005年の本なので http://www.google.com としてましたが、時代に合わせて https としました。

今回はちょっとひねっていて、変数 https を用意していて、名前が被っている時はどうなの?をネタを知っている人には問うているわけですね。

There is no restriction against using the same identifier as a label and as the name of a package, class, interface, method, field, parameter, or local variable.

同じ識別子をラベル名と、パッケージ、クラス、インターフェース、メソッド、フィールド、パラメータ、またはローカル変数名として使用することに制限はありません

§14.7. Labeled Statements

とわざわざ記載があって、ラベル名は変数名と被っていても問題ありません。

Q3 i != i をtrueにせよ

public class Q03 {
 public static void main(String[] args) {
   int i = 0;
   if ( i != i ) {
     System.out.println("Hello, JJUG 2025!");
   }
 }
}

このコードの int i = 0; 部分を書き換えてif文内を動くようにしてください

Q3 解説

これも書籍Java Puzzlersよりパズル29 を改題。想定解としては Float.NaN ないし Double.NaN を使います。

public class Q03 {
 public static void main(String[] args) {
   float i = Float.NaN;
   if ( i != i ) {
     System.out.println("Hello, JJUG 2025!");
   }
 }
}

intで 0/0 をやるとjava.lang.ArithmeticExceptionとなりますが、浮動小数点数ではNaN (Not a Number、非数)となります。要するに「解なし」みたいな感じですね。

0.0/0.0 の他、Math.sqrt(-1) や 10.0 % 0などでNaNが発生しますが、これらは同値ではないですよね? そのためNaN同士の==比較はfalseになりますし、!=比較はtrueになるように設計されています。言語仕様上は以下の通り。

The equality operator == returns false if either operand is NaN.
The inequality operator != returns true if either operand is NaN.

等価演算子==は、 いずれかのオペランドが NaN の場合にfalseを返します
不等号演算子 != は、どちらかのオペランドが NaN の場合にtrueを返します

§4.2.3. Floating-Point Types and Values

あるいは §15.21.1. Numerical Equality Operators == and != を参照してみてください。

Q4 long と int の足し算

public class Q04 {
 public static void main(String[] args) {
   System.out.println(
       Long.toHexString(0x1_0000_0000L + 0xcafebabe));
 }
}

選択肢は

  1. 1cafebabe が出力される
  2. コンパイルエラー
  3. その他

Q4 解説

ポイントは0x1_0000_0000L がlongで 0xcafebabe がintというところ。intがワイドニングされてlongになるのだけども、ここで0xcafebabeは最上位ビットが立っているのでマイナス値(2の補数表現)で0xffff_ffff_cafe_babe L とされてからlong同士の足し算がされるので結果がcafebabeとなってしまいます。つまり正解は3のその他です。

言語仕様には以下のような記載があり

A widening conversion of a signed integer value to an integral type T simply sign-extends the two's-complement representation of the integer value to fill the wider format.

符号付き整数値を整数型Tに拡大変換すると 、整数値の 2の補数表現が単純に符号拡張され、より広い形式が満たされます

§5.1.2. Widening Primitive Conversion

2の補数表現で符号拡張される、つまりintのマイナス値はlongのマイナス値になるわけです。

Q5 int最小値

public class Q05 {
 public static void main(String[] args) {
   System.out.println(-2147483648);
 }
}
// ※ 2147483648 = 2^31

選択肢は

  1. -2147483648 が出力される
  2. コンパイルエラー
  3. その他

Q5 解説

Q5は後続の問いへの枕になっていて、これはそのまま素直に動くというひっかけ問題。正解は1の-2147483648 が出力されるです。

壇上からは会場で聞いている人々の表情が良く見えるのだけど、だんだん疑心暗鬼になってきたところで普通の問題を出したので割とみんな間違えましたね。自分の脳内コンパイラ脳内JVMが信じれなくなってくるのがJava Puzzlersの醍醐味ですね。

言語仕様解説はまとめて後ろで。

Q5-2

public class Q05_2 {
 public static void main(String[] args) {
   System.out.println(-(2147483648));
 }
}
// ※ 2147483648 = 2^31

選択肢は

  1. -2147483648 が出力される
  2. コンパイルエラー
  3. その他

先ほどのQ5に対して()が付け加えられました。さてどうなるでしょうか?

Q5-2 解説

Java Puzzlers より パズル86 を改題。Q5は普通に-2147483648 が出力されました、では -(2147483648) はどうですか?というのが本来の問い。そんな、カッコつけたからって……と思いきや、これはコンパイルエラー。

もう一問見てから解説をしましょう。

Q5-3

public class Q05_3 {
 public static void main(String[] args) {
   System.out.println(-(0x8000_0000));
 }
}
// ※ 2147483648 = 2^31 = 0x8000_0000

今度は16進数表記でカッコ付きです。さてどうなるでしょうか。

  1. -2147483648 が出力される
  2. コンパイルエラー
  3. その他

Q5-3 解説

これは正常動作して-2147483648 が出力されます。さてどういうことなのでしょう?

It is a compile-time error if the decimal literal 2147483648 appears anywhere other than as the operand of the unary minus operator; or if a decimal literal of type int is larger than 2147483648 (2^31).

10進リテラル2147483648が単項マイナス演算子オペランド以外の場所に出現した場合、またはint型の10進リテラルが2147483648(2^31)より大きい場合は、コンパイル時エラーになります。

§3.10.1. Integer Literals

とピンポイントに2147483648(2^31)についての記載があるのが面白ろポイントで、これは 2の補数表現の関係上、int最大値は 2147483647 (2^31-1)で、マイナスは-2147483648(-2^31)と絶対値が1大きい。

そして、-2147483648は構文上はマイナス値のリテラルではなくて、"-"の単項演算子と数値リテラル2147483648になっており、単項演算子"-"の後ろだけ特別扱いで2147483648が許されるという特例になってるんですね。

そしてこれは10進リテラルの場合限定なので16進数表記の0x8000_0000はセーフ。Q5の -2147483648 は正常に動いて、Q5-2の -(2147483648) はコンパイルエラーになるというわけです。

Q6 finallyでreturn

public class Q06 {
 public static void main(String[] argos) {
   System.out.println(decision());
 }
 static boolean decision() {
   try {
     return true;
   } finally {
     return false;
   }
 }
}

try で return しつつ、 finally でも returnしています。さてどうなるでしょうか。

  1. true が出力される
  2. false が出力される
  3. コンパイルエラー
  4. その他

Q6 解説

Java Puzzlers より パズル36 を微修正。この問題は比較的成果率が高かった気がします。finally節でreturnすると値が上書きされる感じですね。正解は 2のfalseです。

言語仕様的にはちょっと独特な表現になっていて

If the finally block completes abruptly for reason S, then the try statement completes abruptly for reason S.
finallyブロックが理由S により突然完了した 場合、try文も理由 Sにより突然完了します

§14.20.2. Execution of try-finally and try-catch-finally

突然完了というのは、要するに順次処理して抜けるんじゃなくて、例外が出る、ないしreturnする、の両方を共通的に仕様として記述しているのかな、と。

finally節がreturn false で完了すると、try節もreturn false で完了、というわけですね。

Q7 発生しない例外をcatch

public class Q07 {
 public static void main(String[] args)  throws Exception {
   try {
     System.out.println("Hello, JJUG 2025!");
   } catch (java.io.IOException e) {
     System.out.println("起きないIO例外");
   }
 }
}

発生しない IOExceptionをcatchしています。どうなるでしょうか。

  1. 正常終了
  2. コンパイルエラー
  3. その他

Q7 解説

tryで発生しない例外をcatchする場合、catch節が到達不能になるんですね。これは 2のコンパイルエラーになります。Javaは到達不能なコードは検出できる限りはコンパイル時にエラーにする方向性なので、そこに寄せた仕様なのかなと思います。詳しい解説は後続の問題の後で。

Q7-2 発生しない java.lang.Exceptionをcatch

public class Q07_2 {
 public static void main(String[] args)  throws Exception {
   try {
     System.out.println("Hello, JJUG 2025!");
   } catch (Exception e) {
     System.out.println("起きない例外");
   }
 }
}

今度は例外が java.lang.Exceptionになりました。

  1. 正常終了
  2. コンパイルエラー
  3. その他

Q7-2 解説

IOExceptionがtryでthrowされないのにcatch(IOException ioe) とするとこれはコンパイルエラー。でも、Q7-2でcatch(Exception e)とするとこれは通ります。正解は1です。
java.lang.Exceptionは特例になっていて、これは java.lang.RuntimeException が extends java.lang.Exception であるという継承階層の不都合がこのあたりの仕様をややこしくしているのでしょう。

該当言語仕様は次のQ8の後に合わせて解説します。

Q8

import java.io.*;
public class Q08 {
 public static void main(String[] args) throws Exception {
   try {
     throwIOException();
   } catch (FileNotFoundException e) {
     System.out.println("ファイルがないケース");
   }
 }
 static void throwIOException() throws IOException {
   throw new IOException();
 }
}

選択肢は

  1. 正常にIOExceptionがthrowされて終了
  2. コンパイルエラー
  3. その他

Q8 解説

Q7, Q7-2, Q8では例外のcatchの型の制約にまつわるパズルでした。Q8の正解は1でコンパイルエラーにはなりません。

It is a compile-time error if a catch clause can catch checked exception class E1 and it is not the case that the try block corresponding to the catch clause can throw a checked exception class that is a subclass or superclass of E1, unless E1 is Exception or a superclass of Exception.

catch節が検査例外E1をキャッチする場合に、対応するtry節がE1のサブクラスまたはスーパークラスの検査例外をthrowできない場合は、コンパイルエラーとなる。
ただし、E1がjava.lang.Exceptionの場合、もしくはjava.lang.Exceptionのスーパークラスの場合を除く。

§11.2.3. Exception Checking

ここで、catch(E1 e1)の場合を仮定しているわけですが、try節でE1がthrowされる、あるいはE1のサブクラスがthrowされるケースは分かると思います。E1のスーパークラスの場合も大丈夫なのはどういうことでしょう?

Q8ではtryで throws IOExceptionなメソッドを呼び出していて、catchはサブクラスのFileNotFoundExceptionなんですね。FileNotFoundException以外のIOExceptionないしそのサブクラスはcatchされずにthrows Exception な mainメソッドを例外で抜けていく。

catchしきらないけど、漏れたものはthrowされるのでセーフ。そしてthrows IOExceptionなメソッドであれば、具象型がFileNotFoundExceptionな例外を投げるかもしれないので、到達可能性的にもセーフ、そういう扱いになります。

これが《対応するtry節がE1のサブクラスまたはスーパークラスの検査例外》の意味合いです。

続く!

さて、問題は全部で15問用意してたのですが、解説が長くなってきたのでいったん区切ります。続きは後編で!