🫠

KotlinとGoをメインで使うエンジニアから見たRust

2024/11/03に公開

業務ではKotlin、個人開発ではGoを中心に開発をしているエンジニアがRustに入門して感じた違いをまとめてみようと思います。
Rustに関してはまだまだ学習中なので見当違いな内容があればご指摘ください。

各言語の違い

表現力

Rustの言語としての表現力について一言で述べると、「丁度いい」だと思います。
KotlinとGoと比較してみます。

Kotlin

Kotlinは非常に表現力が高いです。
例えばCollections(List・Map・Set・etc...)には欲しいと思ったメソッドは大体用意されています。例えば、filter、map、reduceといった高階関数や、partitionでの分割、groupByによる分類など、多くの操作がシンプルに記述できます。

リスト内の偶数の数値を2倍にして合計するというコードをKotlinで記載すると以下のようになります。

val numbers = listOf(1, 2, 3, 4, 5, 6)
val result = numbers.filter { it % 2 == 0 }
                   .map { it * 2 }
                   .sum()
println(result) // 出力: 24

以下のような書き方もできます。

val numbers = listOf(1, 2, 3, 4, 5, 6)
val result = numbers.filter { it % 2 == 0 }
                    .reduce { acc, i -> acc + i * 2 }

さらに拡張関数を定義することでこうした処理を共通化することができます。

fun List<Int>.sumOfEvenDoubled(): Int {
  return this.filter { it % 2 == 0 }
          .map { it * 2 }
          .sum()
}

val result = numbers.sumOfEvenDoubled()

また代数的データ型などもsealedクラスを用いることで表現でき、バグを実行時ではなく、コンパイル時に検出することができます。

sealed class Car(open val name: String) {
    data class ElectricCar(override val name: String, val batteryCapacity: Int) : Car(name)
    data class PetrolCar(override val name: String, val fuelCapacity: Int) : Car(name)
}

fun describeCar(car: Car): String {
    return when (car) {
        is Car.ElectricCar -> "Electric car: ${car.name}, Battery Capacity: ${car.batteryCapacity} kWh"
        is Car.PetrolCar -> "Petrol car: ${car.name}, Fuel Capacity: ${car.fuelCapacity} liters"
    }
}

fun main() {
    val tesla = Car.ElectricCar(name = "Tesla Model S", batteryCapacity = 100)
    val civic = Car.PetrolCar(name = "Honda Civic", fuelCapacity = 50)

    println(describeCar(tesla))  // 出力: Electric car: Tesla Model S, Battery Capacity: 100 kWh
    println(describeCar(civic))  // 出力: Petrol car: Honda Civic, Fuel Capacity: 50 liters
}

上記の例でHydrogenCarを追加して、describeCarにwhenを追加しなかった場合コンパイルが通りません。
さらに関数型のエッセンスを取り入れたい場合はArrowなどを使用できます。

もちろんジェネリクスも使えます。

ここで紹介しきれないほどたくさんの表現方法がKotlinには存在するが故に、新規メンバーは言語のキャッチアップに非常に時間を要します。
ある程度慣れていてもclass・sealed・data・valueなどのそれぞれのクラスのどれを使おうか迷う人も多い気がします。
また、プロジェクト内でその表現力が故に実装者によってコードにばらつきが出る可能性があります。

Go

一方Goは言語仕様は非常にシンプルで、公式のチュートリアルであるA Tour of Goをこなすことである程度実装できてしまうのが魅力です。

しかし、そのシンプルさが故に記述量が増えてしまうことがあります。

先ほどのリスト内の偶数の数値を2倍にして合計するというコードをGoで記載すると以下のようになります。

func main() {
    numbers := []int{1, 2, 3, 4, 5, 6}
    result := 0
    for _, num := range numbers {
        if num%2 == 0 {
            result += num * 2
        }
    }
    fmt.Println(result) // 出力: 24
}

上記を見るとおわかりいただけるように、Goの組み込み関数にmapやfilter・reduceなどは存在しません。したがって基本的にはループと条件分岐で愚直に実装していく必要があります。

samber/loなどのライブラリを使うことでMapやFind・Filterなどの関数を使用できるようになりますが、Goの設計思想と照らし合わせると微妙なところです。

拡張関数は定義できませんが、ジェネリクスはサポートしているので便利なutil関数を定義することはできます。
ただGoでは代数的データ型は表現できません
したがって関数型のエッセンスを取り入れたいなと思ってもその術はありません。

Rust

Rustの表現力は、KotlinとGoの中間に位置します。「ちょうど良い」と感じるのは、これが故です。

まず、標準ライブラリで提供されているイテレータを使うことで、mapやfilter、sumといった操作が可能です。Kotlinほど多くのメソッドは備わっていませんが、基本的な操作は備わっているため、Goのようにすべてを手動で実装する必要はありません。

例えば、リスト内の偶数の数値を2倍にして合計する処理は以下のように記述できます。

fn main() {
    let numbers = vec![1, 2, 3, 4, 5, 6];
    let result: i32 = numbers.iter()
                             .filter(|&x| x % 2 == 0)
                             .map(|x| x * 2)
                             .sum();
    println!("{}", result); // 出力: 24
}

また、代数的データ型を表現するためにRustには列挙型(enum)が用意されています。Kotlinのsealed classと似ていて、条件分岐やデータ構造の違いを型レベルで明示し、コンパイル時にチェックを行うことができます。以下は、Carを列挙型で定義する例です。

enum Car {
    ElectricCar { name: String, battery_capacity: u32 },
    PetrolCar { name: String, fuel_capacity: u32 },
}

fn describe_car(car: Car) -> String {
    match car {
        Car::ElectricCar { name, battery_capacity } => format!("Electric car: {}, Battery Capacity: {} kWh", name, battery_capacity),
        Car::PetrolCar { name, fuel_capacity } => format!("Petrol car: {}, Fuel Capacity: {} liters", name, fuel_capacity),
    }
}

fn main() {
    let tesla = Car::ElectricCar { name: String::from("Tesla Model S"), battery_capacity: 100 };
    let civic = Car::PetrolCar { name: String::from("Honda Civic"), fuel_capacity: 50 };

    println!("{}", describe_car(tesla));  // 出力: Electric car: Tesla Model S, Battery Capacity: 100 kWh
    println!("{}", describe_car(civic));  // 出力: Petrol car: Honda Civic, Fuel Capacity: 50 liters
}

このように、Rustは列挙型とパターンマッチングにより、複数のデータバリエーションを安全に取り扱うことができ、未処理のケースがあればコンパイルエラーで警告が出ます。この機能のおかげで、ロジックの漏れを防ぎやすく、後から型を追加する際にも安全性が確保されます。

と便利な機能を有しながらも、それが多すぎないのがRustの良さだと思います。
後述する所有権の概念は慣れない人には難しいものの、言語仕様としては必要十分なのがRustの魅力です。

エラー処理

Rustでは一貫した例外処理を行うことができ、anyhowを使用することでシンプルな記述ができます。

Kotlin

Kotlinでエラーハンドリングを行う方法はいくつか存在します。
1. 例外を投げる
2. 組み込みのResultクラスで処理する
3. ArrowのEitherで処理する

  1. 例外を投げる

Kotlinでエラーハンドリングの最もオーソドックスな方法は、Javaと同様に例外を投げる方法です。例外が発生する可能性のある処理をtry/catchで囲み、キャッチした例外に応じてエラーメッセージを表示したり、リカバリ処理を行ったりします。

fun divide(a: Int, b: Int): Int {
    if (b == 0) throw IllegalArgumentException("Division by zero")
    return a / b
}

fun main() {
    try {
        val result = divide(10, 0)
        println("Result: $result")
    } catch (e: IllegalArgumentException) {
        println("Error: ${e.message}")  // 出力: Error: Division by zero
    }
}

この方法は、オーソドックスが故に理解がしやすいというメリットがある一方、例外が呼び出し元まで伝播するため、エラーが発生するたびにtry/catchブロックで処理する必要があります。

ユースケースから見た奥深くで投げられた例外をキャッチできておらず意図せず障害通知がきたなんてことが起きる可能性があります。

  1. 組み込みのResultクラスで処理する

Kotlinには、組み込みのResultクラスがあり、関数がエラーを返す可能性がある場合に活用できます。ResultクラスはisSuccessやisFailureで成功・失敗を判定し、成功時の値や失敗時のエラーを取得できます。

fun divide(a: Int, b: Int): Result<Int> {
    return if (b == 0) Result.failure(IllegalArgumentException("Division by zero"))
           else Result.success(a / b)
}

fun main() {
    val result = divide(10, 0)
    result.fold(
        onSuccess = { println("Result: $it") },
        onFailure = { println("Error: ${it.message}") }  // 出力: Error: Division by zero
    )
}

Resultを使うと、エラーの処理を戻り値で受け取れるため、例外を直接投げずに関数の返り値として扱えるメリットがあります。これにより、エラーが発生しやすい箇所を型で表現することができます。

  1. ArrowのEitherで処理する

Kotlinの関数型ライブラリArrowを用いると、Eitherクラスによるエラーハンドリングが可能です。Eitherは左側(Left)をエラー、右側(Right)を成功とする双方向の型です。

import arrow.core.Either
import arrow.core.Left
import arrow.core.Right

fun divide(a: Int, b: Int): Either<String, Int> {
    return if (b == 0) Left("Division by zero")
           else Right(a / b)
}

fun main() {
    val result = divide(10, 0)
    when (result) {
        is Either.Left -> println("Error: ${result.value}")  // 出力: Error: Division by zero
        is Either.Right -> println("Result: ${result.value}")
    }
}

ArrowのEitherを使うと、結果がLeftかRightかで分岐できるため、例外を伴わずにエラーハンドリングが可能です。
また深い話になるので割愛しますが、ensureeitherNelなどの便利なメソッドも使用できます。

Kotlinはエラーハンドリングの方法も多岐に渡るので、人によって例外を投げたり、Resultを使ったりするとコードが返って複雑になりバグの混入リスクが高まります。

Go

Goのエラーハンドリングは、他の言語と異なり「例外を投げる」という方法が存在しません。Goではエラーを関数の戻り値として返し、呼び出し元でそのエラーをチェックするのが一般的です。(panicはある)これにより、エラー処理が明示的となり、コードの流れが追いやすくなるというメリットがあります。

具体的には以下のように、関数から通常の値とエラーを戻り値として返し、エラーが発生していないかどうかを確認する必要があります。

package main

import (
    "errors"
    "fmt"
)

func divide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

func main() {
    result, err := divide(10, 0)
    if err != nil {
        fmt.Println("Error:", err)  // 出力: Error: division by zero
    } else {
        fmt.Println("Result:", result)
    }
}

このように、divide関数はint型の結果とerror型のエラーメッセージを返します。エラーが発生していない場合はnilを返し、発生した場合にはerrors.Newでエラーメッセージを生成して返します。これにより、Goではエラーを呼び出し元でしっかりと検出し、対応することが可能です。

ただこれはエラーチェックが増えるという側面を持つため、Goコミュニティではerrors.Wrapやfmt.Errorfなどを使い、エラーメッセージにコンテキストを追加してエラーの原因を追いやすくする習慣があります。

ただ依然としてそれぞれの階層でif err != nil {は記述しなくてはなりません。

Rust

RustにはGoと同様に例外は存在しません。戻り値でのハンドリングが中心です。(panicはある)Rustにはエラーハンドリング用の標準ライブラリとして、Result型とOption型が用意されています。エラーが発生する可能性のある操作にはResultを使い、エラー発生時にエラーメッセージを返し、それを呼び出し元で処理する形が一般的です。Rustではこのようなエラーハンドリングによって、コンパイル時にエラーの可能性を把握できます。

fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        Err("Division by zero".to_string())
    } else {
        Ok(a / b)
    }
}

fn main() {
    match divide(10, 0) {
        Ok(result) => println!("Result: {}", result),
        Err(e) => println!("Error: {}", e),  // 出力: Error: Division by zero
    }
}

この例では、divide関数がResult型を返しており、計算結果が正常に得られた場合はOkでラップし、エラーが発生した場合はErrでエラーメッセージを返しています。呼び出し元では、match文でOkかErrを確認して処理を分岐させています。

?演算子を使った簡略化
Rustには?演算子があり、これを使うことでエラーハンドリングを簡潔に記述できます。?演算子を使用すると、エラーが発生した場合に即座にエラーを呼び出し元に返すことが可能になります。

fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        Err("Division by zero".to_string())
    } else {
        Ok(a / b)
    }
}

fn calculate() -> Result<i32, String> {
    let result = divide(10, 0)?;  // エラーが発生するとここで早期リターン
    Ok(result * 2)
}

fn main() {
    match calculate() {
        Ok(result) => println!("Result: {}", result),
        Err(e) => println!("Error: {}", e),
    }
}

anyhowクレートを使ったエラーハンドリング
Rustでは、エラーハンドリングをさらに簡素化するために、anyhowクレートがよく利用されます。anyhowクレートは、異なる型のエラーを一元的に扱えるようにし、より柔軟かつ使いやすいエラーハンドリングを提供します。また、エラーメッセージに文脈情報を追加できるため、デバッグがしやすくなります。

use anyhow::{Result, Context};

fn divide(a: i32, b: i32) -> Result<i32> {
    if b == 0 {
        anyhow::bail!("Division by zero");
    }
    Ok(a / b)
}

fn calculate() -> Result<i32> {
    let result = divide(10, 0).context("Failed to perform division")?;
    Ok(result * 2)
}

fn main() {
    match calculate() {
        Ok(result) => println!("Result: {}", result),
        Err(e) => println!("Error: {:?}", e),  // 出力: Error: Failed to perform division: Division by zero
    }
}

anyhow::bail!で簡単にエラーメッセージを返し、contextメソッドで文脈情報を追加しています。anyhowを使うと、エラーハンドリングがシンプルになり、複雑なエラーも一貫した形で処理できるため、エラーメッセージの可読性も向上します。

このようにRustは、デフォルトのResultやOptionで十分なエラーハンドリングが行えつつ、さらにanyhowなどで柔軟性を追加できるため、KotlinやGoと比較してエラー処理が洗練されていると感じます。

メモリ管理

Kotlin

KotlinのメモリはJVMのGCに依存しているため、開発者はメモリ管理をほとんど意識する必要がありません。
実行時のオーバーヘッドがあり、たまにJVMのGCの意図しない挙動に悩まされることがありますが、Webアプリケーション等では基本的にGCがボトルネックになることはありません。

ただ面倒なのは例えばAWS環境の場合、ECS等でKotlinのアプリケーションを実行した時にCloudWatchに表示されるECSのメモリ使用率はJVMが確保しているメモリであり、実際に使用しているメモリではないことです。JVMは実際に使用するよりだいぶ余裕を持ってメモリを確保します。
この問題を解決するにはPrometheus等を使用してメトリクスを収集してGrafana等の可視化ツールで可視化する必要があることです。

Go

GoのGCはJVMのように大きくメモリを事前確保しません。したがってCloudWatchのメモリ使用率は実際の使用量にかなり近いです。
また、GCのオーバーヘッドが比較的小さい(約10-30%程度)のが特徴です。

Rust

Rustは所有権システムによってメモリを管理します。

所有権の基本ルール

  • 各値には一つの所有者が存在する
  • 値が別の変数に代入されると所有権が移動する
  • 所有者がスコープを抜けると値は自動的に解放される

奥が深いの詳細は記載しませんがなんとなくイメージを掴んでもらうために例を記載します。

// Webアプリケーションでよくある例
#[derive(Clone)]
struct User {
    id: i32,
    name: String,  // Stringは所有権を持つ
}

async fn handle_request(db: &Pool) -> Result<Json<User>, Error> {
    // userはこの関数が所有者
    let user = User {
        id: 1,
        name: String::from("Alice"),
    };
    
    // 以下はコンパイルエラー:
    // userの所有権がmoved_userに移動してしまう
    let moved_user = user;
    process_user(user);  // エラー: userは既に移動済み
    
    // 正しい方法:
    // 1. Cloneを使用して新しい所有権を持つ値を作成
    let cloned_user = user.clone();
    process_user(cloned_user);
    
    // 2. 参照を使用
    process_user_ref(&user);
    
    Ok(Json(user))
} // ここでuserは自動的に解放される

この所有権の概念によって

  1. メモリリークの防止
    • 使用されていないメモリを保持し続けるリスクが減る
  2. 並行処理の安全性確保
    • 所有権と借用のルールにより、同じデータに対する同時書き込みや不正な読み取りを防ぐ
  3. パフォーマンスの最適化
    • GCのオーバーヘッドがない
    • コンパイラがメモリ管理の詳細を把握しているため、開発者は最適化に集中できる

といったメリットを享受することができます。
また、GCを使用しないため、CloudWatchに表示されるのは実際に使用したメモリのみです。したがって(メモリのために)追加のメトリクスを収集する必要はありません。

コンパイル時間

Koltin

JVMで動作するので長い(Javaより遅いということはない)です。

Go

個人的な体感ですが、めちゃくちゃ早いです。

Rust

簡単なwebアプリをコンパイルしてみたところある程度時間がかかります。
Goより遅いがKotlinよりはマシという感じです。

型システム

どの言語も非常にリッチな型システムを有しているので明確に「Rustの型システムのここがいいよ!」という段階に達していません。
もし何かある方がいれば教えていただけると嬉しいです。

Discussion