はじめに
「KotlinとC#からみたJava言語」というタイトルからは、KotlinやC#に詳しい人間が、Javaを体験してみた結果の報告というようなものを期待する人もいると思うのだが、これはそうした文章ではない。JavaもC#も遊び程度に触ったことがある人間が『Kotlin In Action』という本に刺激を受けて(また、業務都合でC#の本も一冊読んで)書いたものだ。この本は大変面白い本で、Kotlinの知識だけでなく、Java言語についての見識も深めることができた。
本稿はその知見を、プログラミング言語について考える材料になるのではないかと思い、残すものだ。
Javaの弱点
なぜKotlinやC#を勉強するとJavaの知見が得られるのか。それは両言語ともにJavaを大いに参考にし、それをもっと使いやすくするために設計された言語だからだ。そのため両言語が補った部分を見ると、逆にJavaの何を弱点として認識したのかが分かる。
では一体Javaの弱点とは何なのか。沢山あるのだが、ここでは、①値型定義機能の欠如、②仮想関数の継承、③NULL安全の欠如の3つに絞ることにする。この3つは互いに密接に関連している。
値型定義機能の欠如
値型とは、その実際の値によって表される型のことであり、値型の変数は直接その値を保持する。値型はJavaではbooleanやintやdoubleなどの基本データ型にしか存在しない。その逆は参照型であり、その型の変数は直接値を保持せず、その参照を値として持つ。Javaでは基本データ型以外の全ての型が参照型だ。
Javaでは基本データ型以外の型は、あらかじめ用意されている型もユーザが定義する型も、全て参照型となる。つまりユーザは値型を定義できない。
対照的にJavaを参考にして作られたC#には、初期バージョンの時から構造体というユーザが値型を定義するための言語機能が用意されていた。
値型は2つの値が同じであること(等価性という)の確認とそのコピーを作ることが容易である。例えば、Javaの基本型は等価演算子(==)で2つの値が等しいことを確認できるし、代入演算子(=)で値のコピーを作ることができる。
参照型の場合は、等価演算子ではその参照値が等しいこと、すなわち同一性(この場合は同じものを参照していること)の確認を行うことになるし、代入演算子ではその参照値のコピーを行い、実際の値のコピーは作らない。
Javaの参照型で等価性を確認するためは、equalsメソッドを(同時にhashCodeも)オーバーライドする必要があるし、オブジェクトのコピーが必要な場合は、cloneメソッドをオーバライドするか、コピーコンストラクタを用意する必要がある。
値型がないことが弱点となるのは、アプリケーションではこの2つの機能を使う機会がとても多いからだ。このため、Javaでアプリケーションを作る場合には、そのソースコード中には、大量のボイラープレート(言語仕様上必要となる、お決まりのコードのこと)が埋め込めれることとなる(実際には、Javaのソースコードにボイラープレートが大量に埋め込まれることになるのは、KotlinやC#でプロパティと呼ばれる言語機能がないことがより大きな要因だが、話が煩雑になるのでここでは省略する)。
ではなぜJavaは基本データ型以外の全ての型を参照型としたのか? 理由はJavaの作者のジェームス・ゴスリンに聞いてみるしかないのだが、想像してみることはできる。
Javaはオブジェクト指向言語として大いに喧伝された言語だ。Javaが生まれた当時、オブジェクト指向言語が備えているべき機能として、次の3つが挙げられていた。
- 情報隠蔽
- 継承
- 多態性
Javaはこのうち多態性を仮想関数の継承とオーバーライドにより実現するのだが、これはJavaでは参照型でしか実現できない機能だ。このため、オブジェクト指向言語を志向したJavaは、値型をできる限り排除しようとしたのではないだろうか。むしろ基本データ型を値型としたのはパフォーマンスなどを考慮しての止むを得ない選択だったと言えるだろう。
また、Javaは「演算子のオーバーロードができない」という特徴を持つが、これもユーザ定義の値型を作成できないという特徴と関連していると思われる。
演算子の多重定義についてJavaの作者のジェームス・ゴスリンは以下のようなを言っているそうだ。
おそらく、20~30%の人が演算子のオーバーロードを諸悪の根源と考えていることでしょう。どこかの誰かが演算子のオーバーロードを使って、たとえばリストの挿入に「+」なんかを割り当てたりして、人生をものすごくめちゃくちゃに混乱させてしまったものだから、これに大きなバツ印が付けられてしまったのでしょう。問題の多くは、分別のあるやり方でオーバーロードできる演算子はせいぜい半ダースくらいしかないというのに、定義したくなるような演算子は数千、数百万個もあり、その中からどれかを選ばなければならなくなるのですが、その選択自体が自分の直感に反してしまうというところからきています。
すなわち、ジェームス・ゴスリンは演算子のオーバーロードを嫌っている。
だが、もしJavaが言語機能としてユーザ定義の値型を採用していた場合、演算子のオーバーロードを採用しないという選択は出来なかったのではないだろうか。少なくとも言語の欠陥として目立ったはずだ。
例えば代表的な値型の一つに複素数型がある。これがもし定義できた場合、この型に対するプラス演算子やマイナス演算子が定義できないというのは言語的欠陥だろう。だがもし参照型としてしか定義できなければ、演算子のオーバーロードができなかったとしても、言語的欠陥として目立つことはなくなる。
値型を定義する機能をなくすことで大嫌いな演算子のオーバーロード機能を省くことが可能になる。これはJavaにとって、とても都合が良かったのではないだろうか。
仮想関数の継承
先述した通り、Javaはオブジェクト指向言語として大いに喧伝された言語であり、仮想関数の継承とオーバーライドによる多態性を積極的に支援するような言語設計が行われている。すなわち、特に何も指定しなければ、クラスは継承可能なクラスとなり、クラスで定義した関数は、オーバライド可能な仮想関数となる。
では、この言語仕様の何が問題なのだろうか。
実は、大いに問題がある。仮想関数は、「脆弱な基底クラス(fragile base class)」と呼ばれる、今ではよく知られている問題を引き起こす。このため、現在では使用を控えることが推奨されているのだ。
「脆弱な基底クラス問題」とは、基底クラスのコードが変更されたときに、その変更がサブクラスが期待するものではなくなってしまったために、サブクラスでの不正な挙動を引き起こすという問題だ。(詳しくは、https://en.wikipedia.org/wiki/Fragile_base_class などを参照していただきたい)
この問題を回避するために、Javaの名著として知られている『Effective Java』では、「継承のために設計および文書化する、でなければ継承を禁止する」方法を勧めている。すなわち、継承による多態性を使用する場合は、基底クラスの実装内容を文書として公開する(そうでなければそもそも継承は使わない)ように勧めている。これはオブジェクト指向の重要な要素と言われている「情報隠蔽」を放棄することを意味する。
NULL安全の欠如
基本型以外の型を全て参照型としたために目立ってしまった言語の欠陥がある。それがNULL安全の欠如だ。
Javaの参照型は値を参照する型ではあるが、何も参照していない値(NUL)を持つことができる。この時、通常の値を参照している前提で、その型のメンバ(メソッドやフィールド)を参照するとNullPointerException(いわゆるヌルポ)という例外が発生する。NULL安全とは簡単にいうとこのNullPointerExceptionを発生させない仕組みのことだ。
JavaがNULL安全ではないことが、言語的欠陥と言えるのは、一方でJavaが型安全をうたった言語であるからだ(例えば、C言語は型安全な言語とはいわれないし、NULL参照も問題とされない)。
型安全とは、型に対する不正な操作をコンパイルエラーの検出などで未然に防ぐ仕組みのことだ。例えば、ある変数の型がString型である場合は、その変数にはString型に許された操作以外の操作を行おうとすると、Javaではコンパイルエラーが発生する。NULL参照は、Javaの型安全における唯一の例外となっている。
JavaにおいてNULL安全の欠如が問題となったのは、Javaが大変広く普及した言語であり多くのプログラマがこの問題に悩まされたことが大きいが、基本データ型以外は全て参照型というJavaの特徴もこれを後押ししている。
Javaの名誉のために一言付け加えておくと、Javaが生まれた当時、NULL安全という言葉は無かったように思う。少なくとも一般的に知られた機能ではなかった。このためJavaがNULL安全ではないというのは仕方がないことと言える。
弱点への対策
では、これらの弱点をJavaはどのように克服しようとしているか。また、KotlinとC#はこれらに対する対策をどのように行っているかをみていこう。
値型定義機能の欠如への対策
Javaの場合
Javaではユーザ定義型は全て参照型となるため、厳密には値型を定義することはできないが、値のように振舞う型を定義することはできる。すなわち、equalsメソッドで同値比較が可能で、コピーを作るのが容易な型というだ。但し、この場合は多くのボイラープレートをソースに追加しなければならなくなることは、先に述べた通りとなる。
この問題に対する解決策として、まず世に出てきたのがIDEによるボイラープレートの自動生成という方法だ。しかし、これはソースを作成する時の負担軽減にはなっても、あとで見直す時の負担の軽減にはならない。
そこで出てきたのがLombokというライブラリだ。このライブラリを導入するとクラスにDataアノテーションをつけるだけで、ソースコードからは見えないところで、equalsなどのボイラープレートを自動生成してくれる。
さらに、Java14からはRecordという新しい仕組みが導入され、値型がないというJavaの弱点がさらに補完されることになった。
Kotlinの場合
KotlinはJavaと同様、JVM上で動くように設計された言語だ。このため、基本データ型以外の型は全て参照型であるという性質を持ち、値型は定義できない。但し、Javaと同じで値型のように振舞うクラスを定義することはできる。
クラスにdataという修飾子をつけるだけで、equals、hashCode、toString、copyなどのメソッド自動生成される仕組みが最初から用意されている(これらのメソッドはソースからは見えない)。
C#の場合
先に述べた通り、C#には構造体という値型を定義するための仕組みを持っている。
さらに、匿名型というリテラルのように使える型も、同値比較のためのEquals関数が自動で付与されるため、値型のように使用することができる。
C#7.0からはタプルという型が使えるようになった。これも値型のように同値比較やコピーが容易な型だ。
仮想関数の継承への対策
Javaの場合
final修飾子をつけたクラスを継承しようとしたり、同修飾子をつけたメソッドをオーバライドしようとするとコンパイルエラーとなる。これはJavaに最初からついていた機能だ。基本的にクラスやメソッドには全てfinalをつけるようにすることで、「脆弱な基底クラス」問題を回避することができる。
Java5.0で導入されたアノテーションにより、メソッドをオーバライドする際にはOverrideアノテーションをつけることが推奨されるようになった。これはオーバライド行わない新規メソッドを意図せず作成してしまうというミスを回避するために追加された機能だが、オーバライドを行うことを少しだけ面倒にするという効果がある。
先に紹介した『Effective Java』では「継承よりコンポジションを選ぶ」という方法を勧めている。これは、既存のクラスを継承する代わりに、既存のクラスのインスタンスを参照するprivateフィールドを新たなクラスに持たせ、新たなクラスの各メソッドは、保持している既存クラスの対応するメソッドを呼び出してその結果を返す(これを委譲と呼ぶ)という方法だ。これにより「脆弱な基底クラス」問題を回避することができる。また、大抵のIDEはこの方法を支援する機能を持っている。但し、この方法は大量のボイラープレートを生み出す。
Kotlinの場合
Kotlinでは、デフォルトでクラスをfinalとして扱う。つまり、何も修飾子をつけないクラスは派生クラスを作ることができない。継承を行えるようにするためにはopen修飾子をつけなければならない。
同じようデフォルトでメソッドをfinalとして扱う。つまり、何も修飾子を付けないメソッドはオーバーライド不可となる。オーバライド可能とするためにはopen修飾子を付けなければならない。また、派生クラスでそのメソッドをオーバライドする際にはoverride修飾子を付けなければならない(Javaで対応する機能のOverrideアノテーションは任意)。
Kotlinは「継承よりコンポジションを選ぶ」という方法を積極的に支援するを持っている。byキーワードによってインターフェースから継承されるメソッドを他のクラスに委譲することが簡単にできるようになっている。
Kotlinには、継承によらないクラスの機能拡張の方法として、拡張関数という仕組みが用意された。これは継承の代わりとはならないし、普通のメソッド定義よりも制限が多いが、既存のクラスにメソッドを後から追加する強力な手段となっている。
C#の場合
C#では、クラスにsealed修飾子をつけることでそのクラスの継承を禁止することができる。但し、Kotlinとは違ってこれはデフォルトではない。
Kotlinと同じようにメソッドはデフォルトでオーバライド可能とはならない。オーバーライド可能とするためには、virtual修飾子を付けねばならず、派生クラスでそのメソッドをオーバライドする際には、override修飾子を付けなければならない。
Kotlinのようにクラス委譲の仕組みは用意されていない。クラス委譲を行う際には、Javaと同じようにボイラープレートを書かなければならない。
Kotlinと同じように、既存クラスにメソッドを追加する方法として拡張メソッドという仕組みがある。もっともこの機能は、C#3.0で追加されたものなので、Kotlinがそれを参考に拡張関数という仕組みを言語の仕様に取り込んだということだ。
NULL安全の欠如への対策
Javaの場合
JSR305や先に紹介したLombokなどのライブラリを導入すると、NullableアノテーションやNonNullアノテーションが使えるようになる。これらはNullPointerExceptionの検出を助けてくれる(その仕様はライブラリごとに異なる)。
Java8からは、Nullの可能性がある参照型のラッパークラスとしてOptionalクラスが使えるようになった。このクラスのメソッドはNullとそれ以外の場合とを区別するため、NullPointerExceptionの発生を減らすことができる。
Kotlinの場合
Kotlinの型システムではnull許容型(nullの可能性がある型)とnull非許容型(nullを許容しない型)が区別されているため、NullPointerExceitionの回避が容易になっている。Kotlinで普通に型名をそのまま使って型を(例えばStringのように)宣言した場合、それはnull非許容型となる。null許容型とするためには型名の後に疑問符を(例えばString?のように)付ける必要がある。
null非許容型は、nullを持たないため、NullPointerExceitionを発生させることはない。さらにKotlinは、null許容型に対して次のような構文を用意しNullPointerExceitionを発生しにくくしている。
- 安全呼び出し演算子(?.)
- nullチェックとメソッド呼び出しを結合する演算子
- エルビス演算子(?:)
- nullの代わりにデフォルト値を返す演算子
- 安全キャスト(as)
- 指定された型に値をキャストしようとし、型が違う場合はnullを返す
- 非null表明(!!)
- 任意の型をnull非許容型に変換するための構文。値がnullの場合は例外をスローする
- let関数
- nullチェックとラムダ呼び出しを結合するための構文
C#の場合
2019年9月にリリースされたC# 8.0からはnull許容参照型が使えるようになった。これはKotlinと同じように、型名の後ろに疑問符を付けたときのみ、その変数や関数の戻り値にnullを許可するという機能だ。Kotlinとの違いは、過去のバージョンとの互換性を保つために、null 許容注釈コンテキストを有効にしたときのみ、null許容参照型が使えることだ。
C#にはそれぞれ導入時期は異なるが、NULLに配慮した次のような演算子がある。
- null合体演算子(??)
- 左側のオペランドがnullではない場合、そのオペランドの値を、それ以外の場合は、右側のオペランドの評価結果を返す演算子
- null合体割り当て演算子(=??)
- 左側のオペランドがnullに評価された場合にのみ、右側のオペランドの値を左側のオペランドに割り当てる演算子
- null免除演算子(!)
- オペランドをnull非許容型として解釈するよう指示を与える演算子。コンパイラの静的フロー分析にのみ影響を与え、実行時には影響を与えない
- null条件演算子(?.、?[])
- 左側のオペランドがnull参照ののときにはnullを、それ以外の場合は左側のオペランドのメンバ(フィールドやプロパティやメソッドなどのこと)である右側のオペランドを評価し、その結果を返す。インデクサーを呼び出すときのみ?[]を使用する
最後に
C++言語の設計者であるストラウストラップはその著書『C++の設計と進化』で次のように述べている。
「私が予言したように、Javaは年月を経て新しい機能を身につけていき、その結果単純さという"もともとの長所"を打ち消してしまったが、だからといって性能をあげることもなかった。新しい言語というものはいつだって「単純さ」を売りにし、その後、実世界のアプリケーション向けにサイズも複雑さも増して、生き延びていく」と述べている。
全くその通りだなと思う。JavaやC#は今ではもう十分複雑な言語だし、まだ生まれて間もないKotlinもやがて複雑な言語に育っていくのだろう。生きている言語とはそういうものなのだ。