この記事は、大阪工業大学 Advent Calendar 2022の8日目の記事です。
執筆後のメモ:いいぞ!が加速しすぎてクソデカ内容になってしまいました…🙏
一応Javaとの比較がメインで、楽しい要素は後半(ラムダ式以降)に多くあるのでそこだけでも見てもらえればうれしいです…
ここでは僕がかれこれ2年ぐらいマイクラのMod開発やらAndroidアプリ開発やらでメインで使っているKotlinについて紹介します。
僕は工学部なので関係ありませんが、情報科学部で一部?の学科の方々はJavaに苦しめられる人が多いと聞きました。Javaは苦しい…ですよね?そう!そこでKotlinですよ。
Kotlinはいいぞ(鳴き声
Kotlin is 何
KotlinはJetbrains社により開発されているプログラミング言語です。主にJVM上で動き、Javaのコードと共存できます。
Jetbrains社といえばJava向けのIDEであるIntelliJ IDEAが有名ですね。こいつはJavaで書かれています。つまりは長年Javaと向き合ってきたことによってこの言語の酸いも甘いも知り尽した人達が考えて作り出したBetterJavaな言語がKotlinってわけです。
Kotlinとセットでよく話題に挙がるAndroidについてですが、Googleは2019年よりAndroid開発においてKotlinファーストの強化を掲げたりしています。
公式曰く、GooglePlayStoreのアプリ上位1,000件の80%にKotlinのコードが含まれているらしいです(すごい
特徴
ごちゃごちゃ言うのもほどほどにして、ここからはひたすらコードで紹介します。
ファイル名が記載されているものは、ここで動くコードを確認できます
Kotlinのバージョンは執筆時点で最新の1.7.21。比較などで書くJavaのコードはJava17で動作するものとします。
null安全
Kotlinといえばって真っ先に紹介されるやつ。
例えばJavaでとあるクラスにこんなメソッドがあるとします。
public void clearList(List<?> value) {
value.clear();
}
何の変哲もない危ないコードです。あ、存在意義のことではないです。もしvalueがnullであった場合、value.clear()
の位置でヌルポ吐いて死にます。回避したかったらif (value != null)
を頭に書いてーってやりますよね。
Kotlinで同等のコードを書くと以下のようになります。
fun clearList(value: MutableList<*>?) {
value.clear()
}
細かい違いはさておき、ここで注目すべきは引数の型がMutableList?
型であることです。KotlinではNullを受け入れる型とそうでない型を明確に区別し、前者は<クラス名>?
のように記述します。ちなみにこのコードはコンパイラによってvalue.clear()
の位置でエラーとなります。
コンパイル時点で弾いてくれるなんてイケメン…💜
回避するならそもそもList<*>?
の”?”を消してnullを受け付けないか、value?.clear()
のように書いてnullの際は実行しないようにします(ifを用いたnullチェックと同等の処理がたった一文字で済みます)
この辺はC#やったことある人だと馴染みがあるかも
プリミティブ型を持たない
Javaではint
/double
/boolean
などのプリミティブ型が存在していますが、これらはKotlinではInt
/Double
/Boolean
というオブジェクト型として扱われます。これによってわざわざInteger型と使い分けたりする必要がなくなります。
あとはオブジェクト型ってことでメソッドを生やせるので、
Integer.toString(1);
みたいなコードが
1.toString()
のように書けます。
あとはIntegerのカスみたいな仕様とかに引っかかることも無くなります(そもそもIntegerに相当するクラスがKotlinには無いので)
public void wtfInteger() {
Integer i = 127;
Integer j = 127;
System.out.println("i == j is " + (i == j));
Integer k = 128;
Integer l = 128;
System.out.println("k == l is " + (k == l));
}
このコードはそれぞれどのような結果を出力するでしょうか。ためしてみてね。(ちなみにこういうので==
を使うのは不適切って話は省略)
拡張関数
Kotlinは既存のクラスに新たなメソッドを実装することができます。
例えば
fun Int.plusOne(): Int {
return this + 1
}
は自身の数字にプラス1した値を返すメソッドをIntクラスに対して実装したもので
1.plusOne()
のようにして使うことができます。この機能のおかげでKotlinはJavaで既に存在するクラスに対して独自のメソッドを生やし、それを直感的に使用することが出来るようにしています。(例:File.writeText()
)
暗黙の操作の排除
型変換
Javaでこのようなコードを書いた経験があると思います
public double doubleTypeZero() {
return 0;
}
同じようにKotlinで書くと
fun doubleTypeZero(): Double {
return 0
}
特に問題ないように見えますが、Kotlinではエラーとなります
Javaの場合intからdoubleへの暗黙の型変換が行われているため問題ないこのコードですが、Kotlinではそのような変換は行なわれません。ちゃんと0.0
と書くなどしてDouble型であることを示す必要があります。
これは一見面倒なだけに見えますが、暗黙の型変換によって値の型が分からなくなり、それが原因で引き起こされる訳のわからんバグ(doubleだと思ってたらfloatになってて精度がイカれるとか)を防ぐことができます。
フィールドの初期化
Javaで3つのフィールドを持つクラスを作ったとします
public class ImplicitInit {
public int number;
public boolean bool;
public String str;
}
これらは初期化時にそれぞれnumber = 0
/bool = false
/str = null
が代入されるようになっています。
Kotlinで同じように書きます
class NoImplicitInit {
var number: Int
var bool: Boolean
var str: String
}
残念ながら許されません
つまりはKotlinはJavaのような暗黙の初期化は行なわれず、以下のように全ての初期化を明示的に行う必要があります。
class NoImplicitInit {
var number: Int = 0
var bool: Boolean = false
var str: String = ""
}
おそらくJetbrainsの社員は暗黙の操作によって親でも殺されたんだろうなって思ってます(実際暗黙の初期化がされたオブジェクトのnullチェック忘れて実行時にぬるぽ吐いて死ぬとかざらにあるよね)
メソッドのオーバーロード
Javaでメソッドで引数にデフォルト値を持たせようとするとこういう書き方をすると思います
public static void greet() {
greet("Java");
}
public static void greet(String name) {
greet(name, 20);
}
public static void greet(String name, int age) {
System.out.println("Hello! I'm " + name + ". I'm " + age);
}
public static void call() {
greet();
greet("Caller");
greet("Caller2", 21);
}
このようにJavaの場合はメソッドを複数用意する必要がありますよね
Kotlinだと以下のように書けます
fun greet(name: String = "Kotlin", age: Int = 20) {
println("Hello! I'm $name. I'm $age!")
}
fun call() {
greet()
greet("Caller")
greet("Caller2", 30)
greet(age = 11)
}
このように一つのメソッドにまとめて記述が書くことができます。なんなら最後のgreetのように特定の引数を指定して値を渡したりもできます
ラムダ式
Kotlin超気持ちいいなんも言えねーって要素の入り口です。
まずはJavaで一般的なラムダ式を書きます
public static void printProcessedZero(Function<Integer, Integer> function) {
var result = function.apply(0);
System.out.println("Processed to " + result);
}
public static void call() {
printProcessedZero(i -> {
System.out.println("Original is " + i);
return i + 1;
});
}
ありきたりなやつですね。printProcessedZero()をcall()から呼び、その際に元のiを出力した上でi+1
した値を返すようにしています。
一応call()を呼んだときの実行結果を載せておくと
Original is 0
Processed to 1
こんな感じになります。
ではこれをKotlinで書いてみましょう。
fun printProcessZero(function: (Int) -> Int) {
val reuslt = function(0)
println("Processed to $result")
}
fun call() {
printProcessZero {
println("Original is $it")
it + 1
}
}
かなりすっきりとした見た目になりましたね。
まず注目すべきはfunction: (Int) -> Int
でしょうか。Function<Integer, Integer> function
に比べてかなり直感的な表現になりました。
あとは呼び出す側の変化がかなり大きいと思います。printProcessZeroの邪魔な(かっこ)
が消えて、i
はit
という名前で(勝手に)呼べるようになり、最後のreturnも消え去りました。
ちなみにIDE上ではこのように表示されています
コレクション操作
上で紹介した強力なラムダ式によって、コレクション操作がめちゃくちゃ楽しくなります。
まずはJavaのコードから
public static void stream() {
var list = new ArrayList<Integer>();
list.add(1);
list.add(8);
list.add(5);
list.add(3);
list.stream()
.filter(i -> i % 2 != 0)
.sorted()
.map(i -> "Number:" + i)
.forEach(System.out::println);
}
数字のリストをStreamAPIを使って奇数のみを抽出してソート、その後"Number: 1"
みたいな文字列に変換して最後に1つずつ出力している何の変哲もないコードです。
出力はこんなかんじ
Number:1
Number:3
Number:5
Kotlinで書くと以下のようになります
fun collection() {
listOf(1, 8, 5, 3)
.filter { it % 2 != 0 }
.sorted()
.map { "Number:$it" }
.forEach(::println)
}
美しい….
というわけでめちゃくちゃシンプルになりましたね。やってることは同じなわけですが、かなりコンパクトで見やすくなりました。Iterableを継承するクラスならなんでもこれらの処理が使えるため、StreamAPIすら必要ありません。
arrow(->)を書かなくて済むので記述の際もスムーズに書くことができます。Kotlinのコレクション操作気持ち良すぎだろ!!
データクラス
KotlinではDataClassという特殊なクラスがあります。今回は先にKotlinのコードを見てみましょう。
data class BasicDataClass(
val num: Int,
val str: String,
val bool: Boolean
)
シンプルっすね。classの前にdataが付いてるぐらいで、そこまで大したことなさそうなクラスですね。
ではJavaでこれを表現してみましょう
public class KotlinLikeDataClass {
private final int num;
private final String str;
private final boolean bool;
public KotlinLikeDataClass(int num, String str, boolean bool) {
this.num = num;
this.str = str;
this.bool = bool;
}
public int getNum() {
return num;
}
public String getStr() {
return str;
}
public boolean getBool() {
return bool;
}
public KotlinLikeDataClass copy(Integer num, String str, Boolean bool) {
var finalNum = num == null ? this.num : num;
var finalString = str == null ? this.str : str;
var finalBool = bool == null ? this.bool : bool;
return new KotlinLikeDataClass(finalNum, finalString, finalBool);
}
@Override
public String toString() {
return "KotlinLikeDataClass(num=" + num + ", str=" + str + ", bool=" + bool + ")";
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
KotlinLikeDataClass that = (KotlinLikeDataClass) o;
return num == that.num && bool == that.bool && Objects.equals(str, that.str);
}
@Override
public int hashCode() {
return Objects.hash(num, str, bool);
}
}
なっっっっっっっっがっっっっっっ!!!!
はい。Kotlin君ならたった5行で済むコードと同等の機能を持ったクラスをJavaで表現するとここまで長くなります。なんならこれでも一部省いてます。やばすぎ
よく見るとカプセル化的なコード(getter)もあったりして、「Kotlin側はただのフィールドじゃねえか!」とツッコミが入りそうですが、Kotlinはそもそもフィールドがgetter/setterを持ってたりするのでこれで合ってます(まあその辺は通常のクラスでもそうなるんですけどね)
Kotlin初心者の時は結構脳死でデータクラス使ってたりしてたんですが、これJavaだったらそうはいかないよね。
え?Java14で追加されたRecordClass?いや…ちょっとそれは…一旦置いてもらって…ね?🤫(一応DataClassの方がmutableな値も扱えるとか優位な点はあるよ)
移譲
例えばListからgetを呼ぶ度に取得した内容を出力するWrapperクラスを作るとします
class CustomList<T> implements List<T> {
List<T> list;
public CustomList(List<T> list) {
this.list = list;
}
@Override
public T get(int index) {
var result = list.get(index);
System.out.println(result);
return result;
}
@Override
public int size() {
return list.size();
}
@Override
public boolean isEmpty() {
return list.isEmpty();
}
@Override
public boolean contains(Object o) {
return list.contains(o);
}
@NotNull
@Override
public Iterator<T> iterator() {
return list.iterator();
}
@NotNull
@Override
public Object[] toArray() {
return list.toArray();
}
@NotNull
@Override
public <T1> T1[] toArray(@NotNull T1[] a) {
return list.toArray(a);
}
@Override
public boolean add(T t) {
return list.add(t);
}
@Override
public boolean remove(Object o) {
return list.remove(o);
}
@Override
public boolean containsAll(@NotNull Collection<?> c) {
return list.contains(c);
}
@Override
public boolean addAll(@NotNull Collection<? extends T> c) {
return list.addAll(c);
}
@Override
public boolean addAll(int index, @NotNull Collection<? extends T> c) {
return list.addAll(index, c);
}
@Override
public boolean removeAll(@NotNull Collection<?> c) {
return list.removeAll(c);
}
@Override
public boolean retainAll(@NotNull Collection<?> c) {
return list.retainAll(c);
}
@Override
public void clear() {
list.clear();
}
@Override
public T set(int index, T element) {
return list.set(index, element);
}
@Override
public void add(int index, T element) {
list.add(index, element);
}
@Override
public T remove(int index) {
return list.remove(index);
}
@Override
public int indexOf(Object o) {
return list.indexOf(o);
}
@Override
public int lastIndexOf(Object o) {
return list.lastIndexOf(o);
}
@NotNull
@Override
public ListIterator<T> listIterator() {
return list.listIterator();
}
@NotNull
@Override
public ListIterator<T> listIterator(int index) {
return list.listIterator(index);
}
@NotNull
@Override
public List<T> subList(int fromIndex, int toIndex) {
return list.subList(fromIndex, toIndex);
}
}
これが継承に代わる良い手段って正気か???
ってなるぐらいには長いですね(@NotNull
とかは無視してください)
これはListがInterfaceであるため、全てを実装する必要があることが原因です。しかも動きに変更加えてるのって実質的には
@Override
public T get(int index) {
var result = list.get(index);
System.out.println(result);
return result;
}
ここだけなんですよね。
ではKotlinで書いてみましょう
class CustomList<T>(val list: List<T>) : List<T> by list {
override fun get(index: Int): T {
val result = list[index]
println(result)
return result
}
}
変更を加えていた場所以外は綺麗さっぱり無くなりました。List<T> by list
という記述によって、書いていないメソッドたちは全てlistの実装がそのまま反映されます。うーん最高。
最後に
いかかでしたでしょうか。あなたもKotlinを使いたくなってきましたか?なりましたよね??
途中からJavaでよくね?族への対抗意識を思い出して色々書きすぎましたね。それでもまだまだ紹介し足りない部分もある(言語自体の話{sealed class,inline class,let,also…}はもちろん、kotlinx.coroutinesとかも話したかった)んですが、そこまで書いてたら収集がつかないのでこの辺で。
ちなみに去年のアドカレでグラフ理論を書いてたSeaoftrees08さんがあれでKotlinを扱ったり、彼が卒論のコードをKotlinで書くほどになってしまった原因の半分は僕だったりします(たぶん)
Kotlinはいいぞ。