Open1

【Gleam】Gleam の基本的な書き方を理解する

NanaoNanao

はじめに

Gleam は Beam(Erlang ランタイム)や Javascript ランタイムで動作する関数型言語です。

https://gleam.run/

プロジェクトの作成

Gleam の開発には Gleam 本体と Erlang が必要です。
Devcontainer ならワークスペースに構成を追加する →Debian→Bookworm→ 追加で Erlang と Gleam を選択だけで環境構築が完了します。
エディタ用に VSCode 拡張の Gleam も追加します。

/.devcontainer/devcontainer.json
{
  "name": "Debian",
  "image": "mcr.microsoft.com/devcontainers/base:bookworm",
  "features": {
    "ghcr.io/devcontainers-contrib/features/erlang-asdf:2": {},
    "ghcr.io/devcontainers-contrib/features/gleam:1": {}
  },
  "customizations": {
    "vscode": {
      "extensions": ["Gleam.gleam"]
    }
  }
}

以下のコマンドで Gleam プロジェクトを作成できます。

gleam new app

プロジェクトは既に実行可能な状態になっています。
以下のコマンドを実行するとコードがコンパイルされコンソールにHello from app!と表示されるはずです。

cd app
gleam run

要件

学習用にサンプルコードを作成します。
架空のカフェのモバイルオーダーのロジックを想定しています。

  • 商品は商品名、カテゴリ、価格、在庫フラグを持つ。
  • カテゴリがコーヒーの場合はサイズを Small、Medium、Large から選択できる。サイズに応じて価格が変わる。
  • カテゴリがフードの場合はサイズを選べない。
  • 在庫なしの場合はエラーになる。

レコード

レコードはクラスや構造体のように型つきのフィールドを持つデータ構造です。
型には Int, Float, String, Bool などが使用できます。
pub キーワードをつけるとモジュール外から呼び出せるようになります。

/// 商品レコード
pub type Product {
  // コンストラクタ
  Product(name: String, category: Category, price: Int, in_stock: Bool)
}

カスタムタイプ

列挙型(enum)のように複数の型をまとめるデータ構造も定義できます。
後ほど紹介する case と組み合わせることで網羅的なパターンマッチングが可能になります。

/// 商品カテゴリ
pub type Category {
  Coffee
  Food
}

/// 商品サイズ
pub type Size {
  Small
  Medium
  Large
}

定数

const キーワードを使って定数を定義できます。

/// サービス手数料
pub const service_fee = 50

関数と変数

fn キーワードを使って関数を定義できます。
関数内の最後の式が戻り値になります。return は不要です。
pub キーワードをつけるとモジュール外から呼び出せるようになります。

let キーワードを使うと変数を定義できます。
Gleam の変数は常に不変(Immutable)です。

/// 手数料の計算
fn calculate_fee(base_price: Int) -> Int {
  let price = base_price + service_fee
  price
}

case とパイプ演算子

条件分岐には case を使います。
例えば以下の case では先ほど定義したカスタムタイプを網羅する条件分岐になっています。
case は式なので評価後の値を受け取れます。

その後base_price |> int.to_floatという文がありますが、この|>はパイプ演算子と呼ばれるものです。
パイプ演算子の左側の値を右側の関数の第 1 引数に渡すのが基本的な動作です。複数の関数呼び出しをパイプ演算子で連結できます。
これにより関数の呼び出しが左から右へ順番に記述されるのでコードが読みやすくなります。

また、import することで他のモジュールの関数を使用できます。
例えば int や float パッケージには数値を扱う関数が数多く用意されています。

import gleam/float
import gleam/int

/// サイズ価格の計算
fn calculate_size(base_price: Int, size: Size) -> Int {
  let rate = case size {
    Small -> 0.8
    Medium -> 1.0
    Large -> 1.2
  }
  let price =
    base_price
    |> int.to_float
  float.truncate(price *. rate)
}

/// 商品価格の計算
fn calculate_price(prod: Product, size: Size) -> Int {
  case prod.category {
    Coffee -> calculate_size(prod.price, size)
    Food -> prod.price
  }
}

Result 型

関数の戻り値の型を Result にすると結果の成功と失敗のどちらかを受け取れます。
成功時にOk失敗時にはErrorを返します。それぞれ値を渡すこともできます。

result パッケージには Result を扱う関数が数多く用意されています。
例えば以下ではresult.map()を呼び出しています。
これは第 1 引数が Ok の場合にその値を使用して第 2 引数のコールバック関数を実行します。そしてその結果で Ok の値を上書きします。
パイプ演算子と result を組み合わせると柔軟で読みやすい関数呼び出しを記述できます。

import gleam/result

/// 商品価格の取得
pub fn get_price(prod: Product, size: Size) -> Result(Int, String) {
  let res = case prod.in_stock {
    True -> Ok(calculate_price(prod, size))
    False -> Error("在庫切れです。")
  }
  result.map(res, calculate_fee)
}

コード全体

/src/app/order.gleam
import gleam/result
import gleam/float
import gleam/int

/// 商品レコード
pub type Product {
  // コンストラクタ
  Product(name: String, category: Category, price: Int, in_stock: Bool)
}

/// 商品カテゴリ
pub type Category {
  Coffee
  Food
}

/// 商品サイズ
pub type Size {
  Small
  Medium
  Large
}

/// サービス手数料
pub const service_fee = 50

/// 手数料の計算
fn calculate_fee(base_price: Int) -> Int {
  let price = base_price + service_fee
  price
}

/// サイズ価格の計算
fn calculate_size(base_price: Int, size: Size) -> Int {
  let rate = case size {
    Small -> 0.8
    Medium -> 1.0
    Large -> 1.2
  }
  let price =
    base_price
    |> int.to_float
  float.truncate(price *. rate)
}

/// 商品価格の計算
fn calculate_price(prod: Product, size: Size) -> Int {
  case prod.category {
    Coffee -> calculate_size(prod.price, size)
    Food -> prod.price
  }
}

/// 商品価格の取得
pub fn get_price(prod: Product, size: Size) -> Result(Int, String) {
  let res = case prod.in_stock {
    True -> Ok(calculate_price(prod, size))
    False -> Error("在庫切れです。")
  }
  result.map(res, calculate_fee)
}

テスト

Gleam には標準でテストフレームワークが用意されています。
テストコードは以下のようになります。
パイプ演算子を使ってアサーションをシンプルに記述できます。

/test/app/order_test.gleam
import gleeunit/should
import app/order

pub fn order_test() {
  // 商品価格が取得できること
  order.Product("カフェラテ", order.Coffee, 500, True)
  |> order.get_price(order.Medium)
  |> should.equal(Ok(550))

  // サイズが考慮されること
  order.Product("カフェラテ", order.Coffee, 500, True)
  |> order.get_price(order.Large)
  |> should.equal(Ok(650))

  // 在庫切れの場合はエラーになること
  order.Product("カフェラテ", order.Coffee, 500, False)
  |> order.get_price(order.Medium)
  |> should.equal(Error("在庫切れです。"))

  // フードカテゴリの場合はサイズが考慮されないこと
  order.Product("ミックスサンド", order.Food, 750, True)
  |> order.get_price(order.Large)
  |> should.equal(Ok(800))
}

テストは以下のコマンドで実行できます。

gleam test

その他

  • 繰り返し処理は再帰や list.map()のように関数で行います。for や while はありません。
  • use キーワードを使うとネストしたコールバック関数をフラットに記述できます。
  • Nil 型は Nil 値以外を許さないためError(Nil)のように何も返さないことを表現するのに使います。

まとめ

  • 関数の副作用が少ないためテストしやすい。
  • エラーメッセージが分かりやすい。
  • Rust で開発されているためコンパイルが高速。
  • Elixir などの他の言語とも連携できるのは嬉しい。