8 以降 Java に触れてない Scala エンジニア、Java の書き方を学び直す
はじめに
2022/07から株式会社 FOLIO で働いている田口と申します。
FOLIO のバックエンドは全社的に Scala を採用しており、私も今では日々 Scala を読み書きしていますが、元々は Java エンジニアでした。
FOLIO のバックエンドエンジニア採用では、プロセスの一つとして技術面接があり、候補者様の一番得意な言語を使用してプログラミングをしていただくことになっています。
色々な言語の中で Java で問題を解かれる方も一定数いらっしゃるのですが、面接官を務める私はというと 8 年くらい前に Java 8 で開発を行って以降 Java にほとんど触れてこなかった為、適切なサポートがシュッとできずに、申し訳なくなることがままあります。
面接は新しく入社いただくだろう方との最初のコミュニケーションと捉えており、そこでわたわたとしてまって頼りない印象を与えてしまうのは良くないなという思いがあって、少し学び直してみることにしました。
Scala を書いている期間の方が長くなり、Java の記憶は遥か遠くに消え去ってしまっているので、Java のアップデートの中でもコードの書き方など表層に近い変化を Scala エンジニア目線で学び直すという目線の記事になるかと思います。
Java 24 までにできるようになったことを調べる
Java といえばきしださん。各バージョンの記事を拝見するのが一番良さそうです。
今の時代らしく、生成 AI にも聞いてみましょう。
気になった変更点を Scala と比較してみる
上記の中で気になった変更点を見ていこうと思います。
void main()
void main() {
System.out.println("Hello, world!");
}
最近の Java だとこれで動きます。衝撃的...!
初学者としては、なんで書く必要があるのかわからない呪文を最初に入力しなくちゃいけないのは入口としての体験が悪かったと思いますし、すごく良い改善だと思います。
一方 Scala2 では以下のように書く形だったんですが、
object Main extends App {
println("Hello, world!")
}
Scala3 ですと、このように書くことができます。
// 関数の名前は main である必要がない。
@main def hello(): Unit = {
println("Hello, world!")
}
だいたい Java と一緒!(大雑把な性格)。
ともに main クラスがシンプルな方向に向かっていてとても良いですよね。
var
型宣言が省略できるようになりました。
var i = 0;
Scala でも同様に書けます。
val i = 0
Scala の場合、省略しない場合は以下のように書くのですが、
val i: Int = 0
個人的にはコードの理解のしやすさなどの観点から省略は明らかに冗長であると言える場合に限る方がいいのではないかなと思っています。
Java に対しては型宣言がすごく長くなってしまったりなど別の観点で議論できるかもしれませんね。
これも変更も、Scala とだいたい同じ感覚で使用できそうですね。
List::of
List などの初期化がシンプルに書けるようになりました。
var ints = List.of(1, 2, 3);
昔はシュッと書きたい場合は、以下のように書いていた記憶がありますが、
var ints = new ArrayList<>() {{
add(1);
add(2);
add(3);
}};
こちらに比べてだいぶシンプルになったかと思います。
Scala では、
val ints = List(1, 2, 3)
というふうに書くことができます。だいたい一緒ですね。
Predicate::not
filter 処理を行う際に、判定条件を反転したい場合があると思います。
var ints = List.of(1, 2, 3);
ints.stream()
.filter(Predicate.not(i -> i % 2 == 0))
.forEach(System.out::println);
}
この場合は i % 2 != 0
すればいいのですが、もともと Predicate が定義されている場合に使いまわしたいけど条件は判定したい場合などにこのように書く場面があると思います。
Scala で同様の処理を書く場合、
val ints = List(1, 2, 3)
ints
.filterNot(i => i % 2 == 0)
.foreach(println)
このように書くことができます。Scala では filterNot という関数が用意されているのですが、書き味的には println も含めてほとんど一緒と言って良いかと思います。
record
私が「Java っぽいなぁ」と思うものの一つが、データクラスを作る際に、getter, setter を手動または、Lombok を使って定義することだったのですが、クラスをイミュータブルに定義できる方法が追加されました。
void main() {
var person = new Person("John", 25);
System.out.println(person);
System.out.println(person.name());
System.out.println(person.age());
// Person[name=John, age=25]
// John
// 25
}
record Person(String name, int age) {
}
Scala でも同等に case class というものがあります。
@main def main(): Unit = {
val person = Person("John", 25)
println(person)
println(person.name)
println(person.age)
// Person(John,25)
// John
// 25
}
case class Person(name: String, age: Int)
だいたい書き味は一緒かと思います。
これらは、イミュータブルであることも一つの利点ですが、パターンマッチが使えることも大きな利点と言えます。
Sealed Classes
Scala2 でお馴染みの sealed ですが、Java でも使えるようになりました。
sealed interface SideMenu permits FrenchFries, ChickenNuggets {}
record ChickenNuggets(Integer quantity, Integer pricePerPiece) implements SideMenu {}
record FrenchFries(Integer grams, Integer pricePerGrams) implements SideMenu {}
Enum とは何が違うんじゃい?という話ですが、Enum は曜日や状態フラグなどシングルトンで表現するようなものを定義して、そうでないものは sealed に寄せていくのが良いのでしょうか。
Scala2 でもだいたい書き味は同じようなもので、以下のような書き方になります。
sealed trait SideMenu
case class ChickenNuggets(quantity: Int, pricePerPiece: Int) extends SideMenu
case class FrenchFries(grams: Int, pricePerGrams: Int) extends SideMenu
sealed classes はパターンマッチと組み合わせることで威力を発揮するのですが、Java でも近年はそのようなアップデートが入ってきているようです。次の項目で見ていきます。
Switchやパターンマッチ
switch 文が switch 式になり、sealed class, record やパターンマッチと組み合わせると、強力な使い方ができるようになりました。sealed class は、継承先が明示的に決まっているため、パターンマッチとの相性がとてもいい様ですね。
SideMenu sideMenu = new FrenchFries(100, 2);
var price = switch(sideMenu) {
case ChickenNuggets(var quantity, var pricePerPiece) -> quantity * pricePerPiece;
case FrenchFries(var grams, var pricePerGrams) -> grams * pricePerGrams;
};
System.out.println(price);
この例だと、普通に内部関数でやれやというツッコミをいただくかもしれないのですが、例は例として捉えてください。
Scala での書き方を見てみましょう。
val sideMenu: SideMenu = FrenchFries(100, 2)
val price = sideMenu match {
case ChickenNuggets(quantity, pricePerPiece) => quantity * pricePerPiece
case FrenchFries(grams, pricePerGrams) => grams * pricePerGrams
}
println(price)
switch -> match に変わっただけでほとんど書き味が一緒ですね!!嬉しい!!
Stream Gatherers
Stream API は parallel で動作することを考慮する必要があるということで、畳み込み処理を書くときも処理順序に依存するものは書かない方が良いような感じだったようです。(何も覚えてない)
Stream Gatherers はそれを解決できる、処理順を維持するような仕組みになっているようで、組み込み関数としては Scala でも馴染みのある関数が多いので紹介したいと思います。
scan
いもす法を使う時によく目にすると思いますが、
List.of(1, 1, 0, 2, 2, 1)
上記のようなデータを、先頭から順番に積み上げていくような処理を書くニーズがあります。
List.of(1, 1, 0, 2, 2, 1)
// 先頭から順番に積み上げて出来上がった新しい List を作る
List.of(1, 2, 2, 4, 6, 7)
その場合、これまでだとミュータブルに書いていたと思うのですが(エアプ)、scan を使うと以下のように書くことができます。
var ints = List.of(1, 1, 0, 2, 2, 1);
var results = ints.stream()
.gather(Gatherers.scan(() -> 0, Integer::sum))
.toList();
System.out.println(results);
// [1, 2, 2, 4, 6, 7]
初期値に0を渡して、足し算をしながら証跡を残していくイメージになっていると思います。
Scala ではどう書くかというと、以下のように書きます。
val ints = List(1, 1, 0, 2, 2, 1)
val result = ints.scan(0)(_ + _).tail
println(result)
// List(1, 2, 2, 4, 6, 7)
Scala では scan という標準関数があるのですが、初期値として与えた0が配列の先頭に来てしまい、そのままでは同等の処理にならないので tail という関数で先頭要素を除去しています。
それ以外は同じように読める範疇かと思います。
windowSliding
windowing, sliding などという名前でいろんな言語やライブラリに登場する処理があって、
ある配列をステップ毎に任意のサイズにまとめた配列の配列にするような動きをします。
[1, 2, 3, 4, 5]
// これを size 3 で、1 step 毎に windowing すると...
[[1, 2, 3], [2, 3, 4], [3, 4, 5]]
// のようになる
Java で実装してみると...
var ints = List.of(1, 2, 3, 4, 5);
ints.stream()
.gather(Gatherers.windowSliding(3))
.forEach(System.out::println);
このようになります。
Scala で実装してみると...
val ints = List(1, 2, 3, 4, 5)
ints.sliding(3).foreach(println)
このようになります。だいたい一緒ですね。
Java の windowSliding は step 数は 1 に固定ですが、Scala の sliding は、step 数を変更することもできるようになっており、若干汎用性が高いものになっています。
fold
fold はもう、fold ですよね。畳み込み処理といったらこの関数ということで説明不要でしょうか。
var ints = List.of(1, 2, 3, 4, 5);
ints.stream()
.gather(Gatherers.fold(() -> 0, Integer::sum))
.forEach(System.out::println);
// 15
val ints = List(1, 2, 3, 4, 5)
println(ints.fold(0)(_ + _))
// 15
このような感じで同じような感覚で書くことができます。
複数行文字列リテラル
こちらも説明不要で、同じような感覚で書くことができそうです。
var text = """
nozomi
taguchi""";
val text =
"""nozomi
|taguchi""".stripMargin
Java と Scala の違いなどを改めて
Java のバージョンアップを重ねた末の進化は本当にすごいですね。
Scala との書き味がどんどん変わらなくなっていっているなというのを改めて実感したのですが、それでもなお違う部分ってなんだろう?というのを、少し紹介したいと思います。
Option
Java と Scala の思想の違いとして、null の扱いが一つ挙げられると思います。
Scala は null を絶対に許さないというか、null が混入しづらい作りになっていると思います。
わかりやすいのは、Map::get のシグネチャの差があると思います。
Java は Map::get を使った際にキーに対応する値が見つからない時 null が返ると思うのですが、Scala の場合は Map::get の返り値自体が Option に包まれた状態になっています。
Java においても Optional というデータ構造はあるのですが、後方互換の問題などで、シグネチャの変更はとてもやりづらいと思うので仕方がないと思いますが、Scala においては、標準関数を使っていて、null が返ってくることはまずないはず...(たぶん...)で、値が存在しない可能性があるときは Option に包まれて返ってきます。
近年の Java はわからないのですが、null が混入しうる場面では Optional::ofNullable で Optional に包んで flatMap などで処理を繋いでいく感じのプラクティスとかが生まれていそうかもなと思っています。(エアプ)
for 式
Scala には flatMap のシンタックスシュガーである for 式という構文があります。
var ids = List.of(1, 2, 3, 4, 5);
var nameById = Map.of(1, "Alice", 2, "Bob", 3, "Charlie");
id 一覧と、(id, name)のマッピングがあった場合
- id が 2の倍数のもののうち最初の id を取得して
- マッピングに存在する場合は名前を取得する
- その名前に b という文字が含まれていた場合だけ「いい名前だね!」というメッセージを出力する
という何がしたいんだかよくわからない処理があったとします。
この処理を Java で書くと
var ids = List.of(1, 2, 3, 4, 5);
var nameById = Map.of(1, "Alice", 2, "Bob", 3, "Charlie");
ids.stream()
.filter(id -> id % 2 == 0)
.findFirst()
.flatMap(id -> Optional.ofNullable(nameById.get(id)))
.flatMap(name ->
name.contains("b") ? Optional.of(name + " is a good name!") : Optional.empty())
.ifPresent(System.out::println);
こういう感じになるでしょうか。Optional の考慮を行い flatMap で連鎖をしていくと冗長な書き方になる場面もあるかと思います。
Scala でも同様に flatMap で実装してみると以下のようになります。
val ids = List(1, 2, 3, 4, 5)
val nameById = Map(1 -> "Alice", 2 -> "Bob", 3 -> "Charlie")
ids
.find(id => 2 <= id)
.flatMap(id => nameById.get(id))
.flatMap(name => Option.when(name.contains("b"))(s"$name is a good name!"))
.foreach(println)
少し Java との表現に違いが出てしまったので補足をすると、
- filter + findFirst を Scala だと find という関数で表現できる
- Scala では Map::get は Option を返すため、Option で包み直す必要がない
- Option::when という関数を使って三項演算子とは違う表現で Option を生成している
という違いがあるものの、まぁだいたい一緒かしら?という範疇かと思います。
続いて for 式を使って表現すると
val ids = List(1, 2, 3, 4, 5)
val nameById = Map(1 -> "Alice", 2 -> "Bob", 3 -> "Charlie")
for {
id <- ids.find(_ % 2 == 0)
name <- nameById.get(id)
message <- Option.when(name.contains("b"))(s"$name is a good name!")
} println(message)
このような形で、各 flatMap 内の処理が for 内の一段ずつの処理に対応しており、シンプルに読み書きすることができます。
これは haskell の do 記法が由来かと思いますが、便利な機能の一つかなと思います。
今回は無理やり Option で例を作りましたが、現実的には、Either や IO などのエラーハンドリング周りで活躍することが多い記法になると思っています。
エラーハンドリング
もう一つの大きな思想の違いとして、エラーハンドリングが挙げられると思います。
Scala の場合、Either という「エラーか値のどっちがが入ってる」というデータ構造が存在し、エラーの発生しうる処理を数珠繋ぎに書くことでアプリケーションを構築していきます。似たような構造として、Validated や Ior などもありまして、昔私が書いた記事に詳しい話は書きましたので、ぜひご覧ください。
Java の純関数ライブラリなどでもしかしたら Either とか使う機運あったりするのかしら・・・?少し調べても今でも活発に開発してそうなライブラリは見つけられなかったのですが、もしご存知の方いらっしゃったらお教えください。
おわりに
Java8 以降キャッチアップをしていなかった私が、Scala との書き味の差という視点で、Java8 以降の書き方の変更について、調べてまいりました。
調べて感じたことは、Java の最新を追いかけてる人ならほとんど違和感なく Scala を書くことができそうだということと、逆もまた然りということです。エラーハンドリングの扱い方についてなどの思想自体は少し距離があるかもしれないものの、昔よりもはるかに近づいているなと思った次第です。
この記事を最後まで読んでいただいたような勉強熱心な、そんなあなた!!!FOLIO で Scala アプリを開発してみるのは、いかがでしょうか?
是非カジュアル面談などさせていただければと思います。連絡はお気軽に。
Discussion