Rustacean のための Scala 3 入門
この記事は Rust アドベントカレンダー 2022 12/16 の、Scala アドベントカレンダー 2022
12/17 の記事です.
Rust 流行ってますね. Rust は元々はシステムプログラミング言語らしいですが、フロントエンドのツール開発者が盛んに使っていることから JSer やフロントエンドエンジニアも Rust を触ったことがある人が増えている印象があります.
JavaScript や Ruby のようなゆるゆる動的型付け言語の世界から Rust に触れて、コンパイラがコードをしっかりと検証してくれる快適さにキャッキャっしている人も多いのではないでしょうか.
一方で、 Rust のライフタイムはメモリについて考えないといけないので難しいです. そんなあなたに朗報が.
GC がある Rust こと Scala を使ってみませんか? Scala 3 は Rust を書いているのとほとんど同じ感覚でプログラムを書くことができるみたいですよ?
Why Scala (3)?
- Rust と同様に強い静的型付け言語なのでコンパイル時にコードを検証できます.
- union 型
A | B
, and 型A & B
やシングルトン型type LogLevel = "INFO" | "WARN" | "ERROR"
も使えます.
- union 型
- Rust と同じように不変の値と変数を区別しています. シンタックスも似ていてパターンマッチ、trait や derive が使えます.
- Rust と違ってガベージコレクションがあるのでライフタイムを気にせずにコードを書くことができます.
- リッチな Repl(https://ammonite.io) や worksheet のようにインタラクティブにコードを書けるツールが充実しています.
- Scala.js・Scala Native を使って、JavaScript や ネイティブにクロスコンパイルすることができます.
Write Once. Run Anywhere- scalajs react という React へのバインディングだけでなく Pure Scala なフロントエンドのライブラリLaminar ・ Indigo もあります.
- nodejs 上で動くのでフロントエンドだけでなく、AWS Lambda や GCP cloud functions を書いたり、JVM 非依存の Pure Scala Language Server を書いたりもできます.
- 豊富なライブラリ. Java のライブラリも全て使えます. scaladex から Scala のライブラリを検索できます.
- 関数型プログラミング. Kotlin の arrow-kt や TypeScript の fp-ts と違って Either や Option 型が標準で入っています. 型クラス(trait)や for comprehension の機能もあります.
- サードパーティーの cats、 cats-collections や cats-effect を使えばもっと関数型テイストの強いプログラムを書けます.
- 言語標準のメタプログラミングでコンパイル時の AST 変換を普段のプログラムを書くのと同じような感覚で書けます.
- and more...
Rust | Scala | |
---|---|---|
ビルドツール | cargo + build.rs | sbt, mill |
開発環境 | vscode +rust analyzer | vs code/neovim + metals, IntelliJ + Scala plugin |
linter, formatter | clippy, rustfmt | scalafix, scalafmt |
セットアップツール | rustup | coursier, scala-cli |
メモリ管理 | lifetime | GC |
不変・可変 |
let , let mut で区別 |
val , var で区別. 標準ライブラリのコレクションも immutable と mutable に分かれている. |
文法 | 直積型 struct/ 直和型 enum・パターンマッチ・derives などのマクロ | 直積型 case class/直和型 enum(sealed trait)・パターンマッチ・末尾再帰・derives などのマクロ |
非同期ランタイム | tokio, async-std | Future, cats-effect IO, ZIO |
関数型プログラミング | ??? | cats, cats-effect, ZIO, scalaz |
フロントエンド | wasm? | Scala.js |
Repl | Evcxr | scala標準の repl,ammonite,scala-cli |
Rust - Scala Getting Started
開発環境
Scala を使い始める人の最初のハードルを下げるために開発されている scala-cli というコマンドラインツールがあります. ファイル一つでプログラムを書けるのでとてもおすすめです.
複雑なビルドが必要になったらビルドツールを使いましょう.
cargo に相当するビルドツールは sbt や mill があります.
迷ったらデファクトの sbt がおすすめです. cargo は cargo.toml
ファイルでビルドを定義しますが、sbt は build.sbt
というファイルに Scala の DSL でビルド定義を書きます.
例えば src/main/scala
以下に Scala 3.2.1 でコードを書く場合は次のように build.sbt
を書きます.
lazy val lib = project.in(file("."))
.settings(
scalaVersion := "3.2.1"
)
IntelliJ ユーザーなら Scala プラグインを使うとシュッと開発環境を用意できます.
VS Code ユーザーや軽量なエディターが好みであれば Rust Analyzer に相当する Metals という拡張機能を入れましょう.
文法
次に基本的な文法を比較してみましょう.
変数定義
let a = todo!();
let mut a = todo!();
// workaround reserved word
let r#type = todo!();
Scala では不変な値は val
、可変な値は var
で定義します. 予約語は バックティックで括れば使えます.
val a = ???
var a = ???
// workaround reserved word
val `type` = ???
文字列の interpolation
let (a,b) = (1,41);
format!("{} + {} = {}",a,b,a+b)
"
の前に s
をつけます. 内部的には StringContext
が使われています. これを応用するとコンパイル時に json を検証する json"""{"key": "value"}"""
のような機能を作ることもできます.
val (a,b) = (1,41)
s"$a + $b = ${a + b}"
構造体
struct A {
field1: String,
field2: i32
}
impl A {
pub fn new(field1:&str,field2:i32) -> Self {
Self {
field1,
field2
}
}
pub fn name(&self) -> String {
self.field1.to_string()
}
}
Scala で構造体のようにパターンマッチ可能な型を定義する際は case class
を使います.
※ この case
はswitch
の分岐で使われる case
というよりも データを入れる "箱" のニュアンスの case
です.
case class A(field1: String, field2: Int){
def name: String = field1.toString()
}
object A:
def apply(field1: String, field2: Int) =
A(field1,field2)
enum
enum X {
A{f1: i32,f2: String}
B{f1: String}
}
impl X {
fn name(&self) -> String {
match self {
A(f1,f2) => f1.to_string(),
B(f1) => f1.to_string()
}
}
}
enum
の定義はほとんど Rust と同じです. メソッドを生やす場合は、 extension
を利用します. enum にももちろんパターンマッチできます.
enum X:
case A(f1:Int,f2:String)
case B(f1: String)
object X:
extension (x:X)
def name: String = x match
case A(f1,_) => f1
case B(f1) => f1
ちなみに Scala の enum は以下のコードのショートハンドです.
sealed はその型のサブクラスがそのファイル内でしか定義されていないことを保証するのでパターンマッチで exhaustive check が効きます.
sealed trait X
object X:
case class A(f1:Int,f2:String) extends X
case class B(f1) extends X
def program =
val x: X = ???
x match
case A(f1, f2) => ???
// warning: missing match arm
Rust と異なり、再帰的なデータ構造も容易に定義できます.
enum Tree:
case Node(l: Tree,r: Tree)
case Leaf(i: Int)
データ構造
Scala のデータ構造、特にコレクションは immutable と mutable に分かれています.
次のように scala.collection
のネームスペースからインポートできます.
import scala.collection.mutable.<collection name>
import scala.collection.immutable.<collection name>
Rust の iter
のように map
, filter
, fold
, flatMap
(≒ and_then
) などのコンビネーターが生えています.
コレクションの性能特性は公式サイトにまとまっています.
関数
Rust では Box<dyn Fn>
などと書かないといけないですが、Scala では関数を (arg: 型) => 返り値
と書けます.
val f = (a: Int) => ???
// または
val f: Int => Nothing = a => ???
高階関数
関数は変数に代入することも、メソッドの引数に渡すこともできます.
// 関数を受け取るメソッド
def f(g: Int => String): Int =
val s = g(1)
s.length()
// 関数を受け取る関数
val f :(Int => String) => Int = g => g(1).length()
f(i => (i*100).toString())
// res: Int = 3
// 関数を返す関数(メソッド)
val h: () => (Int => String) =
() => (i:Int) => i.toString()
// 関数を返すメソッド
def h(): Int => String =
(i:Int) => i.toString()
h()
// res: Int = String
h()(1)
// res: String = "1"
関数の合成
関数は compose
や andThen
を使って合成できます.
val f: Int => String = i => i.toString()
val g : String => Int = s => s.length()
val `g ∘ f`: Int => Int = g compose f
val fAndThenG: Int => Int = f andThen g
メソッド
fn f(arg:&str) -> String {
arg.to_string()
}
Rust の fn に相当するメソッドは次のように書けます.
def f(arg:String): String =
arg.toString()
pub(crate)
のような可視性は private
修飾子や protected
修飾子でコントロールできます.
private[mypkg-name] (args:String): String = ???
Generics
fn f<T,S>(a:T) -> S {
todo!()
}
fn f<N:Add>(n:N,m:N) -> N {
n + m
}
ジェネリクス、あるいは型パラメーターは[]
で括ります. 型に関する制約はトレイト境界 T: ATrait
やサブタイピング T <: Parent
で表現できます.
def f[T,S](a:T): S =
???
def f[N:Numeric](n:N,m:N):N = summon[Numeric[N]].add(n,m)
def f(A <: Animal)(a:A):A = ???
trait
trait Show {
fn show(&self) -> String;
}
impl Show for String {
fn show(&self) -> String {
self.to_string()
}
}
fn program() {
let shown = "example".to_string().show();
}
Scala の trait は Java のようにインターフェースを定義するための使い方と、 Rust の trait に近い使い方があります. given
が impl
に対応するイメージで書くといいでしょう.
trait[T] Show[T]:
def show(t:T): String
extension[T:Show](t:T)
def show(): String = summon[Show[T]].show(t)
given Show[String] with
def show(t:String): String = t.toString()
def program() =
val shown = "example".show()
パターンマッチ
let list = vec!["This","is","an","example"];
match &list[..] {
[a,..] if a == "This" => todo!(),
// ["This",..] => todo!() とも書ける
[binding @ a,..] => todo!(),
[] => todo!(),
}
struct A {
field1: i32,
field2: String,
field3: f32
}
let a = A {field1: 1,field2: "a".into(),field3: 1.0};
let A{field2,..} = a;
let a = A {field1: 1,field2: "a".into(),field3: 1.0};
match a {
A {field1,field2,field3} => todo!()
}
Scala でも同様にパターンマッチが可能です. コレクション(List, Seq)、タプル、構造体(case class/enum) だけでなく文字列や正規表現にもマッチができます. ちなみにマッチの arm の実体は PartialFunction です.
val list:List[String] = List("This","is","an","example")
list match
case head :: tail if head == "This" => ???
// case "This" :: tail => ??? とも書ける
case binding @ head :: tail => ???
case Nil => ???
case class A(f1:Int,f2:String,f3: Double)
val a = A(1,"a",1.0)
val A(_,f2,_) = a
a match
case A(f1,f2,f3) => ???
val x:X = A(1,"a")
x match
case A(f1,f2) => ???
case B(f1) => ???
有名なライブラリ
Rust も Scala もアプリケーションを書く場合、標準ライブラリだけでは少し厳しいのでライブラリを使うことになります.
web アプリケーションを作る場合は以下のようなライブラリを使うことになると思います.
目的 | Rust | Scala |
---|---|---|
Json・YAML などのシリアライゼーション | serde (+ serde-json), serde-yaml | circe, circe-yaml |
http | tower | Java 標準、Netty や http4s |
http client | reqwest | 同期処理で問題なく easy 重視なら request-scala, 非同期 IO が必要なら http4s |
gRPC | tonic | scalapb |
rest + openapi doc gen | utopia | tapir |
非同期ランタイム | tokio, async-std | Future,cats-effect IO, ZIO |
test | - | ユニットテストは munit, Property-Based Test は scalacheck などが使える |
db アクセス | sqlx | Quill, doobie, slick, etc. |
これらを全て比較するのは厳しいので Json に絞って比較します.
Json
Rust と Scala でそれぞれ Json パースをするコードを書いてみました.
[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
use serde::{Serialize,Deserialize};
#[derive(Serialize,Deserialize)]
struct Payload {
id: String,
body: Body
}
#[derive(Serialize,Deserialize)]
struct Body {
id: Int,
name: String
}
fn main() {
let payload = r#"{
"id": "xxxxxxxx",
"body": {
"id": 42,
"name": "john doe"
}
}"#;
let model: Payload = serde_json::from_str(payload);
let jsonBack = serde_json::to_string(&model)?;
println!("{jsonBack:?}");
}
Scala のライブラリも Rust の crate の features のように複数のサブパッケージに切られていることがしばしばあります. json のパースや Encoder/Decoder の derive のために core に加えて parser と generic を import します.
以下のファイルは scala-cli json-handling.scala
で実行できます. また scala-cli export --sbt json-handling.scala [--js|--native]
で sbt. プロジェクトに export できます.
//> using scala "3.2.1"
//> using lib "io.circe::circe-core:0.15.0-M1"
//> using lib "io.circe::circe-generic:0.15.0-M1"
//> using lib "io.circe::circe-parser:0.15.0-M1"
import io.circe._
import io.circe.syntax._
import io.circe.parser._
import io.circe.generic.semiauto._
case class Payload(id:String,body:Body) derives Decoder, Encoder.AsObject
case class Body(id:Int,name:String)
@main def run() =
val payload = """
|{
| "id": "xxxxxxxx",
| "body": {
| "id": 42,
| "name": "john doe"
| }
|}""".stripMargin.trim
for
json <- parse(payload)
model <- json.as[Payload]
jsonBack = json.asJson
yield println(jsonBack)
Rust の derive と違って、Scala の例ではトップレベルの型に derives
をつけることでその要素の型についても再帰的に trait の実装を自動導出できます. また import io.circe.generic.semiauto._
を import io.circe.generic.auto._
にすると、 derives
を省略しても必要に応じてトレイトが derive されます.
なんでこんなことができるかって? そう、Scala ですから!
for
json <- parse(payload)
model <- json.as[Payload]
jsonBack = model.asJson
yield println(jsonBack)
Either 型(Result型に相当) で fail-fast に処理を抜けるには for 式を使います.
まとめ
Rust ライクな文法、しっかりとした静的型付け、強力なパターンマッチ、豊富なデータ構造、マクロや関数型プログラミングが GC のおかげで脳死でかける気持ちよさが読者諸君にざっくりでも伝わっていたら幸いです.
日本語であれば ScalaText, Scalapedia 、英語であれば Tour of Scala、関数型プログラミングに関しては cats や cats effect のドキュメントがわかりやすいです.
Rock the JVM という YouTube チャンネルでも Scala のチュートリアルがあります.
2023 年は Scala をはじめてみませんか...?
Discussion