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氏のことで、この推論をやることになんらかの躊躇があったのだろう。

Javaのリスク考察 2018年版

Java10以降、Java SEのリリースサイクルを6カ月単位とすることが発表されてしばらく経ち、また、予告通りJava11からいよいよJavaのサポート体制も変更となった。


本稿ではJavaを使い続けるリスク、あるいはほかの言語ランタイムを使い続けるリスクについて検討してみたい。プログラム言語は用途に応じて様々なものが使い分けられている。特に本稿では選択肢が多く、またJavaのシェアも高いサーバーサイドのWebシステムという戦場で検討してみたい。

ランタイムの有償化リスク

無償で提供されていたプログラム言語のランタイムが、突如有償化するリスクをまず考えてみよう。この点で、Javaはもっともリスクが低い部類であると言える。


JavaのランタイムはGPLライセンスで公開されており、無償でソースコードが公開されている。GPLライセンスは、ソースコードの公開が求められ、また改変を許可し、オリジナルもしくは改変版の複製と頒布が許されるが、その派生物もGPLを求める。なので、独自にJavaを改造・拡張したモノを作ることも許されているが、そうした派生物はソースコードを公開しなくてはいけない。重要なポイントはこれらの「複製と頒布」は有償でおこなっても良いという点である。


ただ、こうした派生物は通常は「Java」を名乗ることができない。これは、GPLライセンスの話ではなく、商標の話となる。特段珍しい話ではなく、改造したものが本家の名前を名乗ってかんばんを乗っ取るというのはできない、というだけの話である。ただし、JavaOracleの定める手続きに則り互換テストをクリアしてOracleと契約すれば「Java」のかんばんを掲げることができる。


繰り返しになるがGPLのポイントとしては、バイナリ頒布にあたっては無償を強制しない。ソースは無料公開されるが、自分でビルドして実行形式を作るところができないのであれば、誰かの作ったバイナリを求めることになる。ここで有償サポート版のバイナリを購入しても良いし、誰かがビルドして無償で公開してくれるものに期待しても良い。バイナリを有償化で配ろうとも、ソースコードは無償で公開されることがライセンス上保証されている。頒布も自由だ。仮にどこかのサポートの値段がたとえ高騰することがあったとしても、常に誰かが無償ビルド版を作って公開する権利が保証されている


また、本家がメンテナンスをしなくなったEOL(End of Life)の過去版についても、どこかの誰かがセキュリティアップデート版を作ったのであれば、GPLの波及によりソースコードを公開することになるだろう。リリースサイクルの変更に伴いサポート期間が憂慮される点はあるが、独自にサポートすることを表明している企業もある。こうした際にも、GPLというライセンスは安心感につながる。


Javaに関して言えば、Oracle以外もJavaランタイムを出している個所は有償無償ともに複数あるOracleはずっと昔から有償サポートを出し続けてきた。Java11でOracleは有償版だけの機能をいくつかOpenJDKに譲渡し、無償化された。
そしてOracleがいままで出してきた無償版の役割はOpenJDKに移管された


JavaSun Microsystemsが開発し、のちにOracleがSunを吸収合併してOracle管轄のものとなった。2010年のことである。OpenJDKの歴史はそれより前の2006年までさかのぼる。OracleがSunを吸収合併したわけだが、OracleはOpenJDKの存在も、JavaGPLライセンスであることも、分かったうえでJavaを手にしたのだ、ということは把握しておきたい。

競合相手は?

JavaGPLなのはわかった。ではほかの言語ランタイムはどうなのであろうか?冒頭述べた通り、Javaのシェアが高いサーバーサイドのWebシステムという戦場で検討してみよう。

環境 ライセンス 開発元
Java GPL Oracle
PHP BSD系 (PHPライセンス) The PHP Group
Ruby on Rails BSD系 ※1 Rails Core Team
Python BSD系(Pythonライセンス) Python Software Foundation
C# BSD系(MITライセンス) Microsoft
Node.js BSD系(MITライセンス)※2 Node.js Developers
Swift Apache-2.0 Apple
Go BSD Google
scala-native ※3 BSD
Kotlin/Native ※3 Apache-2.0

※1 RubyBSDRubyライセンスとのデュアル、RailsがMITライセンス。サーバーサイドということで敢えてRailsを挙げている
※2 Node.js自体はMIT、JSエンジンはGoogle V8 JavaScript Engineだと修正BSD
※3 いずれも通常のScala / KotlinはJava VM で動作させるが、あえて脱Javaを考えるケースでの比較としてscala-native / Kotlin/Nativeを記載した


個々のライセンスの詳細に踏み込むと大変なのでざっくりとした記載としている。BSDライセンスは自由な改変・再配布・無保証といったところである。GPLとは違い、派生物のライセンスへの波及はないApacheライセンスも似たようなものであり、改変も再配布も自由である。派生物のライセンスへの波及もない。もちろん、これらのライセンスも無償を強制しない


ライセンスという観点で見れば、いずれの環境も、オープンな状態に置かれている。

オープンソースな言語ランタイムの有償化戦略

さて、有償化リスクを検討するために逆に「どうすれば有償化できるか?」について考えよう。GPLにせよ、BSDにせよApacheライセンスにせよ、有償で配布することそのものを否定するライセンスではない。しかし、ソースコードが公開であっても、自分で実際に動く状態のバイナリファイルをビルドするというのはなかなかやる人はいない。なので、バイナリを有償配布するという戦略は常にとることができる。全てのユーザーに金を払わせることはできなくても、一部の人はビルドの手間賃だと割り切って払ってくれるかもしれない。


しかし、いずれのライセンスも、自由な再配布を許しているので、誰かがビルドして無償配布することを防ぐことができない。こうした「有志による無償版」は一定以上の言語ユーザーがいれば必ず作られるだろうと思う。

有償サポートとセキュリティアップデート

現実的には、サポート契約が有料というのが実際に行われていることである。


本家の通常版ではすでにEOL(End of Life)となっている古い古いJava7であるとか、Java6であるとか、さらにもっと前であるとかのバージョンについて、もしセキュリティ上の問題が生じたらコードを修正したバージョンを提供しますよ、といったものである。


Javaは比較的後方互換が厚い言語であるが、完全というわけでもない。過去のある時点でつくられたシステムを新バージョンで動かして何かあったらどうする!という理由で古いバージョンのまま有償サポートを受けて使い続けているという現場は結構ある。そこに金をかけるならバージョン追従にお金を使った方がシステムが長持ちするんじゃないかという気もするが、出来上がった時点でスパゲティコードなシステムなら、敢えて長持ちさせる必要もなく、時がたったら作り直せばいいやぐらいに考えているかもしれない :-P


作り直すときに「過去の仕様を全て再現すること」とか言い出して、過去の問題ある動きを頑張って忠実に再現するような話もあったりなかったり。


話がそれた。古いバージョンのJavaに対する有償サポートは昔から行われていることである。しかし、このセキュリティアップデートのコードもGPLであるから無償で公開されているという点は重要なポイントである。BSD系やApacheライセンスでは、それこそこうしたコードを非公開で高値でふっかけることだって可能なんだから。

バージョンアップの際の締め出し

次に、よく言われる陰謀論であるところの「無償で行きわたらせ、容易に抜け出せなくしてから突如有償化して金をせしめる」について考えてみよう。


まず、オープンソースの状態で行きわたっている言語ランタイムが、ある日突然動かなくなる、ということは、まずない。そうした、時限式の締め出しをするコードが公開されているコード中に見つかったら、それは事件だし、もしそのようなものが見つかれば、その時限爆弾を排除した改変版が出回ることだろう。このとき、商標権といったかんばんの都合上、おなじ名前を名乗り続けることはできないかもしれないが。


ライセンスは契約なので権利者側の一方的な通告で過去の契約を変えることはできない。なので、すでに頒布してしまっている過去版を金を払わないなら使うな、と言うことはできない。完全に有償化するとしたら、あるバージョン以降、ライセンスを変えてオープンソースではなくしてしまうか、あるいは、ソースは公開しても二次利用した改変版を自由に作れないようなライセンスにしてしまうことだ


GPLにせよ、BSDにせよApacheライセンスにせよ、自由な改変と再配布ができてしまうので時限爆弾を排除した改変版が出回るのだ。自由な改変と再配布ができないライセンスにしてしまえば、そうした改変版は非合法の「海賊版」となる。合法的に使いたければ必ず金を払え、とさせることができるようになる。


過去のライセンスは変えられないにしても、新バージョンからライセンスを変更することは可能である。しかし

なおライセンス条件を別のものに変更する場合には、対象となっているソフトウェアの著作権者全員が、ライセンスをその別のライセンスに変更することに同意していることが必要です。そのため、リナックスカーネルのように、無数のプログラマーがコミットしている場合には、全員の同意を取り付けるのは非常に難しいですから、ライセンスの変更は事実上不可能でしょう


多数のコミッターが参加しているようなプログラミング言語についていえば、権利的に突然の変更というのは難しいのではないだろうか(参加するにあたってこのあたりの権利を処理するためのなんらかの同意書がとられているかもしれない)。JavaOracleが商標をもっており、開発を主導してはいるがOracleによる独裁政治でつくられているわけではない。


いささか古い記事で申し訳ないが(おそらく2011年の記事)Javaの仕様策定プロセスJCPJava Community Process)とそのメンバーについて

つい最近も、このJCPのExecutive Committee(JCPの運営方法などを決める評議会)の委員を選出する選挙が実施されましたが、投票の結果、「SOUJava」というブラジルの Javaコミュニティが選出されました。このように、Javaは現在も、特定のベンダーが支配することのない、オープンなコミュニティで開発が進められて いるのです。

といったことが語られている。


こうした民主的なプロセスが作られているプログラミング言語の場合、独裁的で強硬なライセンス変更は難しいだろう。

ライセンス変更に対するコミュニティの対応は

この時点ですでにだいぶん「アリエナイ」話になっているが、仮に仮に仮に独裁的で強硬なライセンス変更がされたと仮定しよう。この時に、コミュニティはどうするだろうか?


言語ランタイムの開発が少数によってなされているようなケースでは、ライセンス変更後の対抗版というのはなかなか出てくるものではない。強硬なライセンス変更を行った中央に囲われていない人材で、無償版が作られるかはわからないし、すくなくとも大きな停滞期となるだろう。


もし、開発コミュニティが十分に大きく、強硬なライセンス変更を行った中央に囲われていない人材も多数いるような状況であれば、ライセンス変更がされる直前のソースコードをもとに、以降のバージョンが作られ、クローズドな有償版とオープンソースの無償版に分裂していく可能性がある。しかし、オープンソースの無償版が作られるというのは、いささか楽観的な未来予測と言える。

GPLライセンスは派生版もGPLであることを強要するが、BSDライセンスApacheライセンスは強要しない。GPLライセンスである言語ランタイムは、こうした分裂があったとしても、派生版がGPLであることは保証している点で少しばかり安心感が増す。もっとも、「独裁的で強硬なライセンス変更」という前提となる仮定がまずもってアリエナイものであることは強調しておく。


たとえ、プログラミング言語の追加機能ようなバージョンアップがされないにしても、セキュリティアップデートがされるぐらいにメンテナンスされるのであれば現在その言語ランタイムを使っているプロジェクトが、直ちに立ち往生することはない。それでも、オープンソース版の発展がみこめないとなると、その猶予をもって別の言語ランタイムへの乗り換えが図られることだろう。発展が見込まれる程度にメンテナンスされるのであれば、利用し続けることも可能であろう。


言語のランタイムがオープンソースであれば、別の実装を作ることは容易である。たとえ、言語仕様がISOなどで規格化されたような言語であっても、オープンな実装がなければ、それを作ることは労力的に難しい。(規格化されていれば、個別に権利者と取引しなくとも言語規格をクリアしたことを名乗れるという効果はあるが、作る労力は別の話だ)もちろん、独自に実装しなおしたものであれば、互換性が保たれるかも疑問である。
ランタイムがオープンソースである、派生することができるという状態に置かれていること自体が、ある種の保証であるといえよう。

開発を主導するもの

プログラミング言語のような、相応に大規模で複雑で難易度の高いものを作るというのは、並の労力ではない。作れる人材も限られるが、そうした人材の労力をこれでもか、と投じなければ作り上げることはかなわない。そうした人材の趣味の時間に頼っていては難しく、そうした人材が言語開発に没頭できる環境、つまり、彼らが言語を開発することで暮らしていけるようなサポートがされなければ、なかなか言語の発展がすすまないだろうと言える。


そうした暮らしのサポート、いうなればマネタイズまでもが、言語開発者個々人任せというのは少ない人材をさらに少なくする。そういう意味で、言語開発に企業がスポンサードしているというのはマンパワー的にも大きい。しかしながら、企業のスポンサードというのは大抵ひも付きである。スポンサーの意向というのが関与してくるものである。そして、スポンサードしている企業が傾いたとき、言語ランタイムは危機を迎えることになる。


これは、2010年のSun MicrosystemsOracle に吸収合併されたときに懸念されたリスクである。その時代はJavaの停滞期であった。2006年12月11日にJava6がリリースされてから、2011年7月28日にJava7がリリースされるまで実に4年半もの停滞を迎えたのである。


JavaOracleに移管されてからJavaの仕様策定プロセスであるJCPといった民主的な体制が敷かれたのもSunという中心企業が傾いた際のリスクを痛感したからであろうか。これは私の想像に過ぎないが。もし仮に、今後OracleJavaを手放すようなことが起きるとしたら、その時はSunからOracleに移行した時よりも混乱はずっと少なくて済むのではないかと思う。


こうした主導企業の停滞のリスクは、Microsoft が擁するC#や、Appleが擁するSwiftでも考えられる。もっとも、この2社がそう簡単に傾くとは思えないが。また、企業によるスポンサードがないプログラミング言語についていえば、もっと端的に中心メンバーが引退するリスクを負っている。実際のところ、世の中の小規模オープンソースプロダクトなんてものは、中心となる一人が離れてしまえばぱったりと更新が止まったりするものである。急に盛んに更新されるようになったな、と思えば後継者となる人物が現れた、といった調子である。


個人の力によるところが大きい世界であるからこそ、そうしたリスクが目立つ。うまく組織化できているプロダクトは安心感が強く、スポンサードの含めてリスク分散されていることがより望ましいと言える。

言語ランタイムのリスク

さて、いくつか検討してきたが、

  • Openになっていない言語ランタイムは突如の有償化と締め出しのリスクがなくはない
  • 小規模な開発コミュニティ、あるいは単独スポンサードのプログラミング言語であれば同様の有償化リスクがなくはない
    • ある程度のコミュニティ規模と、民主的な意思決定プロセスがあればリスクは無視できるほど小さい
  • 仮にこれが生じた場合、開発コミュニティが大きければ分裂してオープンソースの無償版が続くだろう
    • コミュニティが小さい場合は維持できなくなる可能性がある
    • 分裂した場合、商標権の関係などで、おなじかんばんを掲げ続けることはおそらく難しい。名前は変わるだろう
  • セキュリティアップデートのような改変版について非GPLでは非公開・完全有料化のリスクが考えられる
  • 開発を主導するスポンサー企業が傾くなどすると、開発が停滞するリスクがある
    • 一社独占のような体制だとこのリスクはやや大きい。もちろん、企業の財務状況が良ければ無視できる
  • 小規模な開発コミュニティの場合、開発の中心人物に何かあると停滞が大きくなる可能性がある
    • ある程度の大きさに組織化できている開発コミュニティであれば小さな影響に抑えることができるかもしれない

現在の主要な言語ランタイムはいずれもオープンソースのライセンス体系をとっている。権利者がある日突然ライセンスを変更する!と言い出したとしても、過去のライセンス契約が無効になるわけではない。すでに自由に改変できる内容のライセンスでオープンソース化されている以上、最悪のケースでも現行バージョンからの派生物を作ることができる。


無償版ばかりが求められているわけでもなく、業務で利用されるサーバー上のJavaなどは、今も昔も有償サポートが用いられてきた。Oracleは今も昔も有償サポートを提供し続ける。Javaサードパーティーからも提供される。これはリスクが分散されているポイントでもある。

ジェネリクスと配列

Javaジェネリクスは一般に配列と混ぜてはいけないとされるが、混ぜて用いた場合に何が問題となるのか。

歴史的な問題

Javaが1995年に登場した当時、Javaに配列はあったがジェネリクスはなかった。

ジェネリクスを含む型システムの理論的な整備は、1990年代から2000年代にかけてのJavaのバージョンアップの時期に並行して行われていた。これは1995年当初のJavaになぜより良いジェネリクスを搭載した形でリリースされなかったのか?ということにひとつの答えを示すだろう。つまり、1995年当時にはジェネリクス(Java5に搭載されたような変性を含むもの)は未来の技術であって、まだ理論的に固まっていないものであった、というわけだ。

Java言語仕様にも記述されているが

Historically, wildcards are a direct descendant of the work by Atsushi Igarashi and Mirko Viroli. Readers interested in a more comprehensive discussion should refer to On Variance-Based Subtyping for Parametric Types by Atsushi Igarashi and Mirko Viroli, in the Proceedings of the 16th European Conference on Object Oriented Programming (ECOOP 2002). This work itself builds upon earlier work by Kresten Thorup and Mads Torgersen (Unifying Genericity, ECOOP 99), as well as a long tradition of work on declaration based variance that goes back to Pierre America's work on POOL (OOPSLA 89).

Javaジェネリクスに現れるワイルドカード、そしてそれによって表現される変性については2002年のAtsushi Igarashi と Mirko Viroliの論文が元になっている。なお、Java5 がリリースされたのは2004年9月30日のことであった。

Javaの配列にはC言語の配列の影響が色濃く見える。配列を表す[]が型の側にも変数の側にもつけて宣言できるのもその影響の一端であろう。

String[] array1;
String array2[];

配列のString型はString型ではない。StringはStringを間接的に扱う。型を間接的に扱うのだから、ジェネリクス的である。C言語の配列が作られたとき、配列がジェネリクスであるという理解でもって設計されたわけではなかろう。しかし、現代から振り返ってみれば、配列は言語に組み込みの機能を限定したジェネリクスである、といえる。

配列はジェネリクスのようなもの

Javaでは参照型は親の型の変数に子の型を代入することはキャストなしに行うことができる。

Object o = new String("hoge");

これはリスコフの置換原則(Liskov, Barbara; Wing, Jeannette 1993年7月16日 Family Values: A Behavioral Notion of Subtyping)として知られる。

ごく端的に言えば、子は親の機能を代替できなくてはならない。代替できる前提において、子を親の型扱いすることができる。こうした変数の型に対して子や孫の型を代入できる関係を「共変性」(covariance)という。Javaの型システムの根幹をなす重要な概念だ。

しかし、型を間接的に用いるジェネリクスでは、変数を単純に共変とすることができない。

ArrayList<String> stringList = new ArrayList<String>();
ArrayList<Object> objectList = new ArrayList<Object>();
objectList = stringList; // コンパイルエラー

なぜか。

objectList = stringList; // 仮に出来たとする
objectList.add(new Object()); // stringList にObjectが格納される

ArrayList

  • get すると Object が返る
  • Object を add できる

ArrayList

  • get すると String が返る
  • String を add できる

ArrayListはgetするとStringを返すわけだが、これはObjectであるわけで、ArrayListのgetの機能を満たす。しかし、ArrayListはStringしかaddすることができず、Objectをaddすることができない。

つまり、ArrayListArrayListが提供する機能をすべて代替できていない。なので子になることはできない。このため、ArrayList<String>のようなパラメタライズドタイプ(parameterized type)は通常の変数の共変性とは異なり非変性とされているのである。Java5でジェネリクスが導入されたことにより、Java言語は変数の変性について複雑さを増した。

「型を間接的に用いるジェネリクスでは、変数を単純に共変とすることができない」と言った理由はここにある。そして配列もまたジェネリクスのようなものであった。なので同じ話題がある。

String[] stringArray = new String[10];
Object[] objectArray = new Object[10];
objectArray = stringArray; // コンパイルエラーにはならない
objectArray[0] = new Object();

上記コードはコンパイルエラーにはならないが、実行すると以下のように例外が出る

Exception in thread "main" java.lang.ArrayStoreException: java.lang.Object

この例外については、出ることが保証されており、1995年当時にJavaの実装者がこの問題を知らなかったわけではない。

しかし、ジェネリクスの変性についての論文が2002年なのであって、1995年当時のJavaに、配列型の変数にだけ共変性を持たせないという言語仕様を求めるのは時系列的に無茶というものだろう。

かくしてJavaの変数は、通常の参照型も配列型も共変性となった。(さらに言えば配列型もObject型に代入可能だったりするのでなお配列型だけ共変としないというわけにはいかなかったのだろう)
Javaの型システムはこの点で欠陥をはらんでいるのである。

配列の構文

枕の話が長くなったが、配列というのは間接的に型を扱うものであるからして、ジェネリクス的なものである。しかし、時系列的な都合もあり、Javaの配列はジェネリクスに統合されていない。Javaの型システムには配列とジェネリクスという似たような別物が含まれることとなった。より後発のScalaなどでは配列はジェネリクスに統合されている。

というわけで、Javaではジェネリクスと配列を混ぜて使うとよろしくない。しかし、じゃあ具体的にどうよろしくないのだろうか?筆者も「混ぜて使うな!」までで話を終わらせてしまってばかりで具体的にどうよろしくないか、までは深入りしたことはなかった。本稿はそれを探るというもので、実用上は「混ぜて使うな!」で十分である :-P

まず、JavaのList<String>のようなパラメタライズドタイプ(parameterized type)は配列にして変数宣言することができる。

List<String>[] listStringArray;

このあたりの根拠をJava言語仕様から探すとなかなか大変なのだが、概ね 8.3. Field Declarationsのあたりに記載があって、大雑把に構文を抜粋すると

FieldDeclaration:
 {FieldModifier} UnannType VariableDeclaratorList ;


UnannType:
 UnannPrimitiveType
 UnannReferenceType


UnannReferenceType:
 UnannClassOrInterfaceType
 UnannTypeVariable
 UnannArrayType


UnannClassOrInterfaceType:
 UnannClassType
 UnannInterfaceType


UnannClassType:
 Identifier [TypeArguments]
 UnannClassOrInterfaceType . {Annotation} Identifier [TypeArguments]


UnannArrayType:
 UnannPrimitiveType Dims
 UnannClassOrInterfaceType Dims
 UnannTypeVariable Dims


Dims:
 {Annotation} [ ] {{Annotation} [ ]}

要するに、UnannArrayType つまり配列は UnannClassOrInterfaceType (クラスorインターフェース)に [] をつけることが許されていて、UnannClassOrInterfaceType は UnannClassType (クラス)とかで、UnannClassType は
Identifier [TypeArguments] と識別子に加え型引数をつけることが許されている、つまり、パラメタライズドタイプを配列にして変数宣言することが構文上できる。[ ]は任意の意味合いだ。

ところが、配列をnewするときの右辺側が問題である。

FieldDeclaration:
 {FieldModifier} UnannType VariableDeclaratorList ;


VariableDeclaratorList:
 VariableDeclarator {, VariableDeclarator}


VariableDeclarator:
 VariableDeclaratorId [= VariableInitializer]

この = VariableInitializer の部分が変数初期化子で

VariableInitializer:
 Expression
 ArrayInitializer

Expression つまり式か、ArrayInitializer 配列初期化子を置くことができる。
まずはExpressionから見てみよう。15.8. Primary Expressionsに定義が載っていて

Primary:
 PrimaryNoNewArray
 ArrayCreationExpression

ここでは ArrayCreationExpression 配列生成式を見ていく。15.10.1. Array Creation Expressionsが該当の節だ。

ArrayCreationExpression:
 new PrimitiveType DimExprs [Dims]
 new ClassOrInterfaceType DimExprs [Dims]
 new PrimitiveType Dims ArrayInitializer
 new ClassOrInterfaceType Dims ArrayInitializer


DimExprs:
 DimExpr {DimExpr}


DimExpr:
 {Annotation} [ Expression ]

ここで、単に new ClassOrInterfaceType DimExprs [Dims] とあるので、パラメタライズドタイプの配列も宣言できそうに見えるが、注釈がついている。

The rules above imply that the element type in an array creation expression cannot be a parameterized type, unless all type arguments to the parameterized type are unbounded wildcards.

パラメタライズドタイプは駄目、型変数は駄目、ワイルドカードは駄目、というわけである。つまり、これらはnew で配列を生成できない。

2018.02.15 追記

指摘いただいて気付いたが盛大に誤訳していた。「全ての型変数が境界を持たないワイルドカードでない限りnewできない」と訳すべき内容で、例えば

ArrayList<?>[] list = new ArrayList<?>[10];

といったコードはコンパイル可能。これは恐らくJava5以前のraw型において

ArrayList[] list = new ArrayList[10];

が可能なことに対応付けられる措置であると思う。

追記ここまで


というわけでVariableInitializerの定義まで戻って

VariableInitializer:
 Expression
 ArrayInitializer

今度は ArrayInitializer について見ていこう。10.6. Array Initializers

ArrayInitializer:
 { [VariableInitializerList] [,] }


VariableInitializerList:
 VariableInitializer {, VariableInitializer}


VariableInitializer:
 Expression
 ArrayInitializer

Javaの構文の細かい所ではあるが、配列の初期化にはいくつか方法があって

String[] array = new String[10];

といった場合は先の ArrayCreationExpression 配列生成式となる。ArrayInitializer 配列初期化子は

String[] array = {"hoge", "piyo"};

といった場合の {} の部分で、これは変数宣言時のみ使用可能で、一般的な式としては用いることができない。
さてこの配列生成式だが、この節にはパラメタライズドタイプの配列生成式が駄目ということは書かれていない。しかし、冒頭に

An array initializer may be specified in a field declaration (§8.3, §9.3) or local variable declaration (§14.4), or as part of an array creation expression (§15.10.1), to create an array and provide some initial values.

といった記述があり、先のArrayCreationExpression 配列生成式などの一部として用いるものである、とあるのでそちらに準じるのだろうか。

言語仕様上、ArrayInitializer 配列初期化子でパラメタライズドタイプが用いれないことについては根拠がはっきりしなかったが、Oracle JDK 9.0.4 を用いて確認を行ったがコンパイルエラーとなることは確認できた。言語仕様の記述の対応を探すのは今後の課題である。


なお、配列生成式でパラメタライズドタイプの配列を作ろうとした場合は以下のようなコンパイルエラーが出る。参考まで。

Hoge.java:44: エラー: 汎用配列を作成します
List[] listStringArray = {};

パラメタライズドタイプの配列生成方法

こうしたことから、パラメタライズドタイプの配列は変数型としては宣言できるものの、配列のインスタンス生成は行えないように見える。しかし、実際にはインスタンス生成をする抜け道がひとつある。可変長引数である。

static <T> T[] toArray(final T ... ts) {return ts;}

このようなメソッドを宣言して

List<String> list = new ArrayList<>();
list.add("hoge");
List<String>[] listStringArray = toArray(list, list, list);

System.out.println(listStringArray.length);
System.out.println(listStringArray[0]);

このように用いるとパラメタライズドタイプの配列インスタンスを作ることができる。

配列の共変性に関わる問題

さて、Javaの配列は歴史的経緯から共変な変数であることは冒頭述べた。
パラメタライズドタイプの場合、これはどうなるか。

List<String>[] listStringArray = toArray();
List<Object>[] listObjectArray = toArray();
listObjectArray = listStringArray; // コンパイルエラー

これは要するに List<Object> に List<String> が代入できないため、 List<Object> にも List<String>が代入できないのである。これだと一見うまくいっているように見える。しかし

List<String>[] listStringArray = toArray(new ArrayList<String>());
List<? extends Object>[] listObjectArray = toArray(new ArrayList<Object>());
listObjectArray = listStringArray; // OK
listObjectArray[0] = new ArrayList<Object>();

List<String>を代入可能なList<? extends Object>の配列を用意すると、この代入可能に引きずられてList<? extends Object>にList<String>が代入できてしまう。ここまでは配列と同等なのだが、イレイジャの都合もあって実行時にArrayStoreExceptionが出ない。

とはいえ、List<? extends Object>であるためadd(new Object());はコンパイルエラーとなる(この理由は拙稿 ジェネリクスの代入互換のカラクリ あたりを参照して欲しい。ワイルドカードは入出力を制限することで共変性や反変性をもたせることが出来る)

listObjectArray[0].add(new Object()); // コンパイルエラー

基本的に、配列の共変性を引きずるので、X に Y が代入可能なら X に Yが代入可能と判断される。
型変数の場合は境界によって代入可能となっていれば同じように配列も代入可能となる。

static <T1, T2 extends T1> void foo() {
	T1[] t1a = null;
	T2[] t2a = null;
	t1a = t2a;
}

特に使い道は思いつかないが。

追記 配列をバインド

逆パターンを書き忘れていたので追記。

型変数に配列をバインドすることはできるか? できるのである。

List<int[]> x = new ArrayList<int[]>();
x.add(new int[] {1, 2, 3});
int[] intArray = x.get(0);

Java9時点では型変数にプリミティブ型をバインドすることは出来ないが、int[]は配列なので参照型である。参照型だからバインドすることができる。

しかし、配列の共変性は相変わらず問題になる。

List<Object[]> y1 = new ArrayList<Object[]>();
List<String[]> y2 = new ArrayList<String[]>();
y1 = y2; // NG
y1.add(new String[] {});

y1 = y2 はパラメタライズドタイプの非変性によってコンパイルエラーとなる。しかし、y1.add(new String[] {})は配列の共変性によりコンパイルエラーとはならない。

むしろプリミティブ型配列であれば共変性の問題が無い分だけ安全かもしれない。共変性が問題となるケースに気をつければ挙動自体には問題はない。どうしても使いたいケースが生じた場合は十分に注意されたし。

まとめ

  • 配列は言語組み込みの機能限定版ジェネリクスのようなもの
  • Javaは後付けでジェネリクスを追加した際、配列をジェネリクスに統合できなかった
  • そのため配列とジェネリクスは似て非なるものとなり相性が悪い
  • パラメタライズドタイプの配列や、型変数の配列、ワイルドカードの配列などは変数型として宣言可能である
  • ただし、基本的にそれらの配列のインスタンスを生成することはできない
  • 配列の共変性の問題がこれらの配列でも生じる
  • イレイジャの都合でArrayStoreExceptionが出ない
  • 基本的に使わないべき

言語仕様から感じられることとしては、パラメタライズドタイプの配列や、型変数の配列、ワイルドカードの配列は基本的に使わせたくないのだろう。

Java Generics Hell - new T()

Java Generics Hell アドベントカレンダー 24日目。

読者の推奨スキルとしてはOCJP Silverぐらいを想定している。

今回はJavaジェネリクスでは型変数を用いてnew T()できないけど特に問題ないという話。

コンストラクタの形

コンストラクタは、その内部でthisが使えるしインスタンスフィールドへのアクセスもインスタンスメソッドへのアクセスも出来る。そこからするとインスタンススコープのメソッドのように見えるが、インスタンスに所属しているわけではない。


インスタンスメソッドを呼び出すにはインスタンスを指定するか、自分のインスタンスのメソッド呼び出しでthis.を省略できる、というシチュエーションでなくてはならない。逆に言えば通常のclassのnewはインスタンスなしで呼び出せるわけで、所属としてはclassのstaticに相当する。クラス名を指定する必要があるが、インスタンスを指定する必要はない、という独特のポジションである。


なので、継承を用いてinterfaceや親クラスによってコンストラクタの引数の形を規定することは出来ない。通常の継承埒外にいるわけである。コンストラクタはリスコフの置換原則の範囲外で、親クラスのコンストラクタと同様に子クラスのコンストラクタが呼べる必要はない。


こうした前提を踏まえると、型変数のような任意の型に対して、統一的なnewをしたいというのはJavaの型システムの範疇で考えると無理であるし、無茶な要求と言える。Javaあたりの世代のプログラム言語ではコンストラクタの引数がどのようなものか、指定することが出来ないし、指定したければ継承の範囲を超えた別のプログラミングパラダイムが必要になる。

デフォルトコンストラク

さて、そんなわけで汎用にコンストラクタの形状を定めるというのは無理で、どうしてもやりたければ、インスタンスを生成するクラスというものを用意するという迂遠なやり方をする必要がある。迂遠というか面倒くさいわけだが、こうした生成を行うクラスを別途用意する(ビルダークラスとかファクトリークラスという呼ばれ方をする)ことで対処できるといえば出来る問題である。いわゆるボイラープレートというやつで、定型句がわさわさ出てきて煩わしいという話はあるにせよ、だ。


しかし、特定のシチュエーションでは決まったコンストラクタの型をしているという前提を置くことが、ある程度妥当性を持つことがある。デフォルトコンストラクタである。

インスタンスの生成を行うメソッド

デフォルトコンストラクタとは要するに引数なしのコンストラクタで、単なるデータを保持するようなクラスの場合、概ねデフォルトコンストラクタを持つという前提を想定してよいケースがある。例えばO/Rマッパーのようなケースで、所定の型のデフォルトコンストラクタでインスタンスを作ってリフレクションでデータを詰めて返したい、そのメソッドがジェネリクスで具象型を指定したい、というわけである。

public class ORMapper {
	public <T> T select(String query) { ... } // これでは生成できない
}

この場合、Javaでやるなら先に挙げたようにビルダークラスを使う必要がある。そして、Java8以降を前提とするならば、ビルダークラスはラムダ式ないしメソッド参照でよい。引数にjava.util.function.Supplier<T>を用いよう。

public class ORMapper {
	public <T> T select(Supplier<T> builder, String query) {
		T ret = builder.get();
		// (略) 詰め込む処理
		return ret;
	}
}

こうした場合、呼び出し側は

ORMapper mapper = new ORMapper();
Hoge hoge = mapper.select(Hoge::new, "select * from hoge");

といった具合である。今後のJavaのリリースで変数宣言時の型推論の導入が検討されている。それが導入されると次のように書けるようになるだろう。

ORMapper mapper = new ORMapper();
var hoge = mapper.select(Hoge::new, "select * from hoge");

C#の場合

さて、引き合いによく出されるC#では実行時にバインドされた型変数の型が引き回される。そして、new制約というデフォルトコンストラクタを持つことを型変数の制約とする特殊機能を使って(型クラスのような汎用機能ではないので過渡期の対処法だと思っておいた方が良いと思う)型変数でのnewを実現している。

https://docs.microsoft.com/ja-jp/dotnet/csharp/language-reference/keywords/new-constraint

class ItemFactory<T> where T : new()
{
    public T GetNewItem()
    {
        return new T();
    }
}

ここでさっきのJavaのORMapperの例をC#にしてみると

public class ORMapper {
	public T Select<T>(String query) where T : new()
	{
		T ret = new T();
		// (略) 詰め込む処理
		return ret;
	}  
}

といった形になるだろう(筆者はC#は不案内なので誤りがあればご指摘願いたい)
この時、呼び出し側は

ORMapper mapper = new ORMapper();
var hoge = mapper.select<Hoge>("select * from hoge");

といった形になるだろう。
Java版(変数宣言時の型推論あり)と比較してみよう。

ORMapper mapper = new ORMapper();
var hoge = mapper.select(Hoge::new, "select * from hoge");

違いとしては、C#側ではメソッドスコープの型変数に対するバインドを明記する必要があり、代わりにnew制約を使うので引数にビルダークラスを取らなくて良い。Java側ではメソッドスコープの型変数は型推論で済ませる代わりにnew制約がないため引数にHoge::newを渡す必要がある。概ね、Hogeの位置が変わる程度で大差がない。

まとめ

  • new制約が便利に見えるが、言うほど劇的な利便性が得られるわけでもない
  • Javaでnew制約の代わりにTから実行時の型をとってリフレクションでnewInstance()しようというのは筋の悪いアプローチ
  • 筋の悪いアプローチをしようとして出来ないからといってイレイジャに八つ当たりするのは見当違いではないか

イレイジャのせいにする言説もしばしば見かけるが、いささか見当違いと言えるだろう。何を問題視しているか冷静に検討したいところだ。