Scalaで学ぶ関数型プログラミングのエッセンス
以前からバックエンド開発における関数型プログラミング (以下FPと呼びます) の活用に興味があり、普段使っているTypeScriptから離れて関数型のプログラミング言語であるScalaを学んでみることにしました。
この記事で紹介する内容は、FP初心者である私が理解できた範囲でエッセンスをまとめたものです。網羅的な内容にはなっていませんが、現場で役に立ちそうな考え方を中心に紹介していきます。
より堅牢なコードを書くために
純粋関数 (Pure Functions) と副作用 (Side-effects)
与えられた引数によって結果が決まる関数を純粋関数と呼びます。純粋関数はどこから何度呼び出そうと同じ値を返すため、テスト容易性が高く扱いやすい点が特徴です。以下の関数は引数で受け取った値のみを使って処理を行い、結果を真偽値 (Boolean) で返しています。
object StockOps {
// 製品の在庫が1つ以上存在するかチェック
def hasAnyStockAvailable(productId: ProductId, stocks: List[Stock]): Boolean = {
stocks.exists(stock => stock.productId == productId && stock.quantity > 0)
}
}
class StockOpsSpec extends AnyFunSpec {
describe("hasAnyStockAvailable") {
it("should return true if any stock is available") {
val productId = ProductId(1)
val stocks = List(Stock(productId, 1), Stock(ProductId(2), 2))
// ✅ 引数によって結果が決まるのでテストしやすい
assert(StockOps.hasAnyStockAvailable(productId, stocks) == true)
}
}
}
純粋関数の持つこのような性質は参照透過性として知られていますが、反対に参照透過でない関数は副作用を持っているということになります。副作用は関数の外側にある変数や、IOといった外部から書き換えられる可能性のある要素に依存しているため、「初回の呼び出しは正しい値を返すが、2度目は意図しない値を返す」といったことが起こり得ます。
def hasAnyStockAvailable(productId: ProductId): Future[Boolean] = {
// 在庫の一覧を外部APIから取得する (副作用)
fetchStocks().map { stocks =>
stocks.exists(stock => stock.productId == productId && stock.quantity > 0)
}
}
それでは副作用を無くせばよいのかと考えますが、アプリケーションを構成する要素としてネットワークアクセスやファイル読み書き、標準出力へのアウトプットなど、何らかの副作用は欠かせないものです。そのため、副作用との付き合い方としては、純粋関数と区別して適切に分離するということになります。
// ✅ 副作用を関数として分離して、戻り値の型 `Unit` で値を返さないことを表現する
def updateStock(stock: Stock): Unit = {
// 外部APIで在庫を更新する(副作用)
}
不変性(Immutability)
不変性は定義された値が変わらないことを保証する性質です。val
で定義した定数には値を再代入できません。
val members: List[String] = List("Alice", "Bob")
members = List("Alice", "Bob", "Peter")
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// Reassignment to val members
プログラミング言語によっては、定数として定義したコレクションの値をメソッドで書き換えることができます (JavaScriptの Array.prototype.push
など)。一方で、Scalaのコレクションはデフォルトでscala.collection.immutable.Listからインポートされており、値を書き換えるメソッドを一切持ちません。
val members: List[String] = List("Alice", "Bob")
val newMembers = members.appended("Peter")
print(members) // List(Alice, Bob)
print(newMembers) // List(Alice, Bob, Peter)
appended メソッドのシグネチャを見ると、List
を返す関数として定義されていることがわかります。このように、計算効率が明らかに悪い場合などを除いて、FPでは値の不変性保証するようなコードが推奨されます。
appendedメソッドのシグネチャ
不変なドメインモデル
不変性の考え方をドメインモデルに適用することで、コードに対する信頼性を高めることができます。以下のコードは受注の状態遷移 (出荷待ち -> 出荷済み) を PendingShipmentOrder
クラスにカプセル化したものです。インスタンスの不変性を守るため、ship
メソッドはセッターによるプライベートフィールドの書き換えではなく、状態遷移後のステータスを持つ受注インスタンスを返しています。
// 複数ある受注モデルの共通部分をまとめたインターフェース
sealed trait Order {
def orderId: OrderId
def items: List[OrderItem]
def status: OrderStatus
}
// 出荷待ちの受注
case class PendingShipmentOrder (
orderId: OrderId,
items: List[OrderItem],
status: "pending_shipment",
) extends Order {
// 出荷済みの受注へ状態遷移
def ship(): ShippedOrder = ShippedOrder(
orderId = this.orderId,
items = this.items,
status = "shipped",
shippedAt = Instant.now()
)
}
コンパイラーで不正な値を検知
上記で取り上げた受注モデルですが、ステータスごとに分割せず、共通のドメインモデルとして定義することも考えられます。
type OrderStatus = "pending_shipment" | "shipped"
case class Order(
orderId: OrderId,
items: List[OrderItem],
status: OrderStatus,
shippedAt: Option[Instant] // Optionは "値がないかもしれない" を表現する型
)
しかし、このようなドメインモデルは現実世界に存在しない状態を持つインスタンスも生成できてしまう欠点があります。
// 🚫 出荷待ちの受注が出荷日を持っている
val invalidOrder = Order(
orderId = OrderId(2),
items = List(OrderItem(ProductId(3), 1)),
status = "pending_shipment",
shippedAt = Some(Instant.now()) // Someは "Optionの値が存在する場合" を表現する型
)
「不正な値が入るかもしれない」ということは、レイヤードアーキテクチャにおいて「常にバリデーションをするか」という判断を迫られます。ドメインモデルの呼び出し元を限定して集中的にテストすることで対策可能ですが、どうしてもコーディング規約といった人の手によるルールが必要です。
そのため、アプリケーション上で常に正しいドメインモデルが動作していることをコンパイラーが保証してくれるのは大きなメリットだと考えています。
さらに安全なドメインモデル
ここでNewtype パターンとコンパニオンオブジェクトを用いた、より安全なインスタンス生成について考えます。NewType パターンはプリミティブな型に意味を与えて、コンパイラーによって型を区別する手法です。これにより、「受注を削除する関数に商品IDを渡してしまい、誤って受注を削除してしまった」という類のミスをコンパイラーによって防げます。
opaque type OrderId = Int
object OrderId {
def apply(id: Int): OrderId = id
}
val orderId = OrderId(1)
val productId = ProductId(1)
// 🚫 異なる型を持つIDを比較している
orderId == productId
// ^^^^^^^^^^
// Found: ProductId
// Required: OrderId
続いて、コンパニオンオブジェクトの紹介です。通常、private
アクセス修飾子が付いたコンストラクタは外部から呼び出せません。
case class PendingShipmentOrder private (
// ^^^^^ privateアクセス修飾子をプライマリコンストラクタに付与
orderId: OrderId,
items: List[OrderItem],
status: "pending_shipment"
) extends BaseOrder {
def ship(): ShippedOrder = ShippedOrder(
orderId = this.orderId,
items = this.items,
status = "shipped",
shippedAt = Instant.now()
)
}
// newキーワードによるインスタンス生成はエラーになる
val order = new PendingShipmentOrder(
// ^^^^^^^^^^^^^^^^^^^^
// constructor PendingShipmentOrder cannot be accessed as a member of PendingShipmentOrder
orderId = OrderId(1),
items = List(OrderItem(ProductId(1), 2), OrderItem(ProductId(2), 3))
)
Scalaでは同じファイル内にクラスと同名のコンパニオンオブジェクトを定義することで、オブジェクト経由でクラス内の プライベートメンバにアクセスできます。コンパニオンオブジェクトには外部からアクセスできるため、PendingShipmentOrder
クラスを生成する唯一のファクトリーとして機能します。このようなパターンをスマートコンストラクタと呼びます。
case class PendingShipmentOrder private (...){...}
// コンパニオンオブジェクト
object PendingShipmentOrder {
// `apply` メソッドはクラスを関数のように呼び出した際に実行される
def apply(orderId: OrderId, items: List[OrderItem]): Either[OrderCreationError, PendingShipmentOrder] = {
if (items.isEmpty) {
// 受注は1つ以上の商品を持たなければならない
Left(EmptyItemsError)
} else {
Right(new PendingShipmentOrder(orderId, items, "pending_shipment"))
}
}
}
// コンパニオンオブジェクトを経由するとエラーにならない
val order = PendingShipmentOrder(
orderId = OrderId(1),
items = List(OrderItem(ProductId(1), 2), OrderItem(ProductId(2), 3))
)
apply
メソッドの内部では受注明細 PendingShipmentOrder.items
コレクションに対する空チェックが実装されています。PendingShipmentOrder
クラスのインスタンスを生成には必ずスマートコンストラクタを経由することが強制されるため、アプリケーション内の出荷待ち受注モデルは常に正常な値を持っていることが保証されます。それでは、商品が0件でバリデーションエラーになった場合はどうなるのでしょうか?
エラーは値(Errors as Values)
説明が遅れましたが、PendingShipmentOrder.apply
は Either
型を返します。Scalaではエラーや例外を型として扱うための仕組みを言語レベルでサポートしており、Option/Either/Try
といった型で表現できます。
def apply(orderId: OrderId, items: List[OrderItem]): Either[OrderCreationError, PendingShipmentOrder] = {
// ^^^^^ Eitherは "失敗または成功" を表現する型
if (items.isEmpty) {
Left(EmptyItemsError)
} else {
Right(new PendingShipmentOrder(orderId, items, "pending_shipment"))
}
}
通常、例外をスローする関数のハンドリングは呼び出し側に委ねられます。関数の内部を理解していれば正しく処理できますが、ハンドリングを忘れたとしてもコンパイルエラーにはならず、実行時エラーに繋がってしまいます。
一方、Either
のようにエラーを値として返すことで、エラーが発生する可能性があることを型として表現できます。これにより、呼び出し側にエラーのハンドリングを強制することができます。ここでも「コンパイラーで不正な値を防ぐ」という Scalaの強みが出ています。
// 呼び出し側はエラーをハンドリングしないと受注モデルを受け取れない。
// 以下の例で `getOrElse` はエラーの場合に例外をスローする
val order = PendingShipmentOrder(
orderId = OrderId(1),
items = List(OrderItem(ProductId(1), 2), OrderItem(ProductId(2), 3))
).getOrElse(throw new IllegalArgumentException("Invalid order"))
val shippedOrder = order.ship()
print(shippedOrder)
より関数型プログラミングらしいコードを書くために
第一級関数 (First-class Functions) と高階関数 (Higher-order Functions)
第一級関数とは、関数を値として扱える性質です。Scalaをはじめとした関数型の言語だけでなく、JavaScriptなどにも広く取り入れられています。値として扱えるということは、関数を引数として渡したり、戻り値として返すことができます。以下は純粋関数の説明で使用したコードですが、stocks.exists
には無名関数を引数で渡しています。
def hasAnyStockAvailable(productId: ProductId, stocks: List[Stock]): Boolean = {
stocks.exists(stock => stock.productId == productId && stock.quantity > 0)
}
もちろん、名前を付けて定数として定義することもできます。
def hasAnyStockAvailable(productId: ProductId, stocks: List[Stock]): Boolean = {
val hasStockFn = (stock: Stock) => stock.productId == productId && stock.quantity > 0
stocks.exists(hasStockFn)
}
ところで、このように関数を引数で受け取ったり、関数を戻り値で返したりする関数のことを高階関数と呼びます。FPでは高階関数をパイプラインのように連結して、1つのワークフローを構築することができます。
// 引数 `fn` はフィルターの評価式に使われる関数
def filterStocks(stocks: List[Stock], fn: Stock => Boolean): List[Stock] = {
stocks.filter(fn)
}
// 引数 `fn` はソートの評価式に使われる関数
def sortStocks(stocks: List[Stock], fn: (Stock, Stock) => Boolean): List[Stock] = {
stocks.sortWith(fn)
}
式 (Expression) とパターンマッチング (Pattern Matching)
Scalaをはじめとした多くの関数型言語は、値の構造を分解して条件分岐するパターンマッチングという機能を標準サポートしています。また、if
や for
といった構文も文 (Statement) ではなく、値を返す式として提供されています。FPはこういった式を活用して純粋関数つくり、それらを組み合わせて一連の処理を行うパイプラインを構築する世界観となっています。
型によるパターンマッチング
一例として型によるパターンマッチングを見てみます。以下のコードでは、受注ステータスに応じた条件分岐を型安全かつ網羅的に行っています。この機能はクラスに限らず、Option/Either/Try
や Future
(非同期で値を返すかもしれない型) といった文脈付き型などにも広く利用できるため、活用の幅がとても広いです。
order match {
case pending: PendingShipmentOrder =>
println(s"Pending Shipment Order: ${pending.orderId}")
case shipped: ShippedOrder =>
println(s"Shipped Order: ${shipped.orderId}")
}
関数合成 (Function Composition) と Railway Oriented Programming
関数合成とは、複数の関数を組み合わせて新しい関数をつくる手法です。Scalaではfor 内包記法 (for-comprehensions) を使うことで、エラーハンドリングと戻り値の型を合成した新しい関数をつくることができます。以下の例では、商品購入ユースケースに関する一連のワークフローを複数の関数から合成しています。
class CreateOrderUsecase(
orderCreateService: OrderCreateService
)(implicit ec: ExecutionContext) {
def execute(
orderId: OrderId,
items: List[OrderItem]
): Future[Either[OrderCreationError, ShippedOrder]] = {
for {
// Step 1: 受注を作成
order <- orderCreateService.createOrder(orderId, items)
// Step 2: 在庫状況を確認して、注文可能であるかチェック
availableOrder <- orderCreateService.parseToAvailableStock(order)
// Step 3: 商品の購入数量に応じて、在庫を引き当てる
_ <- orderCreateService.reduceStock(availableOrder)
// Step 4: 受注ステータスを出荷済みに遷移
shippedOrder <- orderCreateService.shipOrder(availableOrder)
// Step 5: 受注を永続化
_ <- orderCreateService.saveOrder(shippedOrder)
} yield shippedOrder
}
}
この for
式は、すべての処理が成功した場合に出荷済みの受注を返し、いずれかのステップが失敗した場合に OrderCreationError
というカスタムエラーを返します。このようにEither型を使って、処理の成功パス (Right) と失敗パス (Left) を路線のように繋げて処理する手法はRailway Oriented Programmingとして知られています。
for 内包記法とEither型を用いたワークフローの特徴:
- 処理が成功 (Right) であれば次のステップに進み、失敗であれば以降のステップをスキップ (ショートサーキット評価) する
- 条件として、すべてのステップをEither型に畳み込む必要がある
- for 内包記法はflatMapのシンタックスシュガーであり、ステップが増えるごとにネストが深くなる問題を解消する
おわりに
今回はScalaでFPのエッセンスを学びました。TypeScriptの言語機能でも踏襲できるものや、ライブラリのサポートが必要なものまでありますが、どれもプログラミングの技法として知っておくと活用の幅が広がりそうです。Scalaにも興味を持ったので、今後も少しずつ学んでいこうと思います。
Discussion