型で設計する ― Scala 3 実践ガイド:基礎から型レベルプログラミングまで
このチュートリアルは
scala-cliを使いながら、 ステップごとにコンパイラと対話して進める構成だ。 各ステップで最も大切なこと:まずコンパイル、エラーを読み、それから理解する。
対象読者
型パラメータ ― List[Int]、Option[String]、Map[K, V] ― は日常的に使っているだろう。
でも [+A]、[B >: A]、match 型、=:= となると別の言語に見えるかもしれない。
「上級者向け」と言われて飛ばしてきた。それはまったく自然なことだ。
このチュートリアルでは、それらは「上級」ではなく、 解決する問題を見れば自然に理解できると主張する。 概念はまっすぐ積み重なり、どれもすでに経験した具体的な問題に対応している。
なぜこれが重要なのか
根本的な問題はこうだ:JVM ではジェネリクスは**消去(erasure)**される。
実行時には List[Int] も List[String] もただの List だ。
JVM はコレクションがどの型を保持しているか知らない。
つまり、コンパイル時が型エラーを捕まえる唯一のチャンスだ。 このチュートリアルで扱うすべて ― 変位、不透明型、型クラス、マッチ型 ― は、消去が情報を捨てる前にコンパイラにより多くの情報を与え、 より多くのミスを検出するためのものだ。
これは今、さらに重要だ。LLM が人間よりも多くのコードを書く時代、 型システムは人間の意図と生成されたコードの間にある重要な層になる。 明示的なコンパイラメッセージを持つ豊かな型システムは、LLM が黙って落とすもの ― 暗黙の慣習、抜けたアノテーション、微妙な変位の要件 ― を検出する。 コンパイラは忘れないし、幻覚も見ない。すべての行を、毎回チェックする 唯一のレビュアーだ。
構成
Part 1: 型システム(Step 0〜5)では、Scala の型システムを効果的に使う方法を扱う ― 型パラメータ、変位、境界、不透明型、型メンバー、型クラス。 ほとんどのプロダクションコードで必要な知識だ。
Part 2: 型レベルプログラミング(Step 6〜8)では、計算する型を扱う ― マッチ型、コンパイル時検証、型の等価性の証明。 これらのテクニックはライブラリ設計やドメインモデリングで登場し、 型システムだけでは捕捉できないバグの種類全体をコンパイラが検出できるようになる。
セットアップ
# scala-cli をインストール(まだの場合)
curl -sSLf https://scala-cli.virtuslab.org/get | sh
# 確認
scala-cli version
# Scala 3.x が必要です
サンプルファイルの使い方
本書のすべてのコード例は examples/ ディレクトリにそのまま実行できるファイルとして用意されている。リポジトリをクローンして、任意の例を実行してほしい:
git clone https://github.com/hanishi/scala3-design-with-types.git
cd scala3-design-with-types
# 特定の例を実行
scala-cli run examples/step0/step0.scala
# またはステップのディレクトリに移動して
cd examples/step2
scala-cli run step2c.scala
各ファイルは自己完結している ― 読んでいるステップに対応するファイルを選んで実行するだけだ。
注意: 必ず1ファイルずつ実行すること ― ディレクトリ全体をコンパイルしない(例:
scala-cli compile examples/step2/)こと。各ステップのファイルは、進行を示すために同じクラス(Product、Book、Boxなど)を異なるシグネチャで意図的に再定義している。まとめてコンパイルすると重複定義エラーになる。
Part 1: 型システム
Part 1(Step 0〜5)では、Scala の型システムを効果的に使う方法を扱う ― 型パラメータ、変位、境界、不透明型、型メンバー、型クラス。ほとんどのプロダクションコードで必要な知識だ。
Step 0: 型のない世界
型情報が失われると何が起きるか、体感してみよう。
0-1. 何でも入るリスト
// step0.scala
@main def step0(): Unit =
// In Scala, you can't omit type parameters.
// But you can use Any to make a list that holds everything.
val xs: List[Any] = List(100, "hello", true, 3.14)
// What do you get when you pull something out?
val first: Any = xs.head // It's 100... but the type is Any
// Try to do arithmetic — compile error
// val doubled = first * 2
// [error] value * is not a member of Any
// [error] val doubled = first * 2
// [error] ^^^^^^^
println(first) // prints 100
println(first.getClass) // class java.lang.Integer
試してみよう:
> scala-cli run step0.scala
first * 2 のコメントを外してコンパイルしてほしい。エラーを読もう。
コンパイラは Any に * メソッドがないと言う。
ポイント: 実行時には値は 100 だが、コンパイラはそれを知らない。 型情報が失われた瞬間、コンパイラはもう守ってくれない。
Step 1: 型パラメータは契約である
1-1. 自分だけの Box を作る
// step1a.scala
// A box that holds anything (no type parameter)
class AnyBox(val value: Any)
// A box with a type parameter
class Box[A](val value: A)
@main def step1(): Unit =
// AnyBox: easy to put things in
val anyBox = AnyBox(42)
// val n: Int = anyBox.value // Compile error! Any is not Int
val n: Int = anyBox.value.asInstanceOf[Int] // Dangerous cast
// Box[A]: the type is preserved
val intBox = Box(42) // Inferred as Box[Int]
val m: Int = intBox.value // Comes out as Int — no cast needed
// Wrong type? Compile error.
// val s: String = intBox.value // error: Found Int, Required String
println(s"AnyBox: $n, Box[Int]: $m")
試してみよう:
> scala-cli run step1a.scala
次に val s: String = intBox.value のコメントを外してみてほしい。
ポイント: Box[A] の A は中に入れたものを「覚えている」のではない。
コンパイラは構築時に A = Int と確定する。これが契約だ。
値を取り出すとき、それは Int であることが保証される。
1-2. 関数の型パラメータ ― 契約は伝播する
// step1b.scala
def first[A](xs: List[A]): A = xs.head
@main def step1b(): Unit =
val names = List("Scala", "Rust", "Go")
val top: String = first(names) // Compiler resolves A = String
println(top)
val nums = List(1, 2, 3)
val one: Int = first(nums) // Compiler resolves A = Int
println(one)
// What about this?
val mixed = List(1, "two", 3.0)
val what = first(mixed) // What is A here?
println(what.getClass)
型を確認してみよう:
scala-cli run step1b.scala
# 推論された型を見る(コンパイルキャッシュを回避するため先にクリーン):
scala-cli clean step1b.scala && scala-cli compile step1b.scala -O -Xprint:typer 2>&1 | grep "mixed"
mixed は List[Int | String | Double] と推論される ― これはユニオン型で、Scala 3 の機能だ。
List[Int | String | Double] とは一度も書いていないことに注目してほしい ― コンパイラが推論したのだ。
本書を通じて、コンパイラが文脈から型パラメータを導き出す能力に頼る。
型推論が内部的にどう動くかはそれ自体が一つのテーマだが、ここでは単にそれが機能すると信頼し、
型が何を意味するかに集中する。
Step 2: 変位と境界
変位はルールを暗記するものじゃない。 「なぜコンパイルが通らない?」をコンパイラと一緒に繰り返すうちに身につく。 出発点は不変(invariant)― 互換性なし。デフォルトが不変であること自体に意味がある。
2-1. 不変 ― デフォルトの壁
// step2a.scala
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double) extends Item:
override def toString = s"Book($name, $$${price})"
class DVD(val name: String, val price: Double) extends Item:
override def toString = s"DVD($name, $$${price})"
// Define our own Box. The type parameter is [A] — invariant.
class Box[A](val value: A)
@main def step2a(): Unit =
val bookBox: Box[Book] = Box(Book("Scala in Depth", 45.0))
// Book is a subtype of Item. So is Box[Book] a subtype of Box[Item]?
// val itemBox: Box[Item] = bookBox
// [error] Found: (bookBox : Box[Book])
// [error] Required: Box[Item]
// [error] val itemBox: Box[Item] = bookBox
// [error] ^^^^^^^
println("Box[Book] is NOT Box[Item]")
コメントを外してコンパイルしてみよう。
コンパイラは Box[Book] と Box[Item] をまったく別の型として扱う。
Book <: Item だからといって Box[Book] <: Box[Item] にはならない。
記法:
<:は「〜のサブタイプ」、>:は「〜のスーパータイプ」。Book <: Itemは「Book は Item のサブタイプ」― つまりBook extends Item。 この記号はこの章を通じて、文章でも Scala コード(型境界)でも使う。
コンパイラのデフォルト:「安全だと示してくれ。そうすれば通す。」
2-2. なぜ不変がデフォルトなのか ― 壊して理解する
// step2b.scala
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double) extends Item:
override def toString = s"Book($name, $$${price})"
class DVD(val name: String, val price: Double) extends Item:
override def toString = s"DVD($name, $$${price})"
// What if Box[Book] could be used as Box[Item]?
// Note: var means the value can be reassigned — this box is mutable (read AND write).
class Box[A](var value: A)
@main def step2b(): Unit =
val bookBox: Box[Book] = Box(Book("Scala in Depth", 45.0))
// Try uncommenting the next line and compile:
// val itemBox: Box[Item] = bookBox
// [error] Found: (bookBox : Box[Book])
// [error] Required: Box[Item]
// [error] val itemBox: Box[Item] = bookBox
// [error] ^^^^^^^
//
// The compiler rejects this. But imagine if it didn't — what could go wrong?
// itemBox.value = DVD("The Matrix", 19.99) // Item allows DVD...
// val book: Book = bookBox.value // ...but now a DVD comes out as a Book!
println("The compiler prevents this — a mutable box must be invariant")
これは思考実験だが、Java では実際に起きる:
// Step2bJava.java — Java arrays are covariant, but generics are invariant
import java.util.List;
import java.util.ArrayList;
class Item {
String name;
Item(String name) { this.name = name; }
}
class Book extends Item {
Book(String name) { super(name); }
}
class DVD extends Item {
DVD(String name) { super(name); }
}
public class Step2bJava {
public static void main(String[] args) {
// --- Arrays: covariant (unsafe) ---
Book[] books = { new Book("Scala") };
Item[] items = books; // Compiles — Java arrays are covariant
items[0] = new DVD("The Matrix"); // ArrayStoreException at runtime!
// --- Generics: invariant (safe) ---
// List<Book> bookList = new ArrayList<>();
// List<Item> itemList = bookList; // Compile error! Java generics are invariant.
// Java learned from the array mistake — generics don't allow this.
}
}
試してみよう(javac と java が必要):
javac Step2bJava.java && java Step2bJava
警告なしでコンパイルされるが、実行時に ArrayStoreException でクラッシュする。
Java のジェネリクスは不変だ ― List<Book> は List<Item> ではなく、
コンパイラが代入を拒否する。例のジェネリクスセクションのコメントを外して確認してほしい。
配列はより早くに設計されており共変 ― 実行時クラッシュの原因はここにある。
Scala の不変デフォルトは配列を含むすべてに適用され、このクラスのバグをコンパイル時に潰す。
2-3. 共変にする ― +A の意味
// step2c.scala
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double) extends Item:
override def toString = s"Book($name, $$${price})"
class DVD(val name: String, val price: Double) extends Item:
override def toString = s"DVD($name, $$${price})"
// Same Box, but now with +A — covariant.
class Box[+A](val value: A)
@main def step2c(): Unit =
val bookBox: Box[Book] = Box(Book("Scala in Depth", 45.0))
// In 2-1, Box[A] rejected this. The + is what makes the difference.
// Book is an Item, so Box[Book] can now be used as Box[Item].
val itemBox: Box[Item] = bookBox
println(itemBox.value) // Book(Scala in Depth, $45.0)
+A を付けると、サブタイプ関係がコンテナを貫通する:
Book <: Item なら Box[Book] <: Box[Item]。
この Box は値を保持するだけだ。中身を検査したり変更もしたくなったら?
2-4. コンパイラのチェックを試す
ブロックを一つずつコメント解除してみよう。
// step2d.scala
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double) extends Item:
override def toString = s"Book($name, $$${price})"
class DVD(val name: String, val price: Double) extends Item:
override def toString = s"DVD($name, $$${price})"
// Uncomment and compile each block one at a time:
// 1. Try +A with a var — var lets you write a new value in, violating +A.
// class Box[+A](var value: A)
// [error] covariant type A occurs in invariant position in type A of variable value
// [error] class Box[+A](var value: A)
// [error] ^^^^^^^^^^^^
// 2. OK, only a val. But what about checking what's inside?
class Box[+A](val value: A):
def contains(item: A): Boolean = value == item
// [error] covariant type A occurs in contravariant position in type A of parameter item
// [error] def contains(item: A): Boolean = value == item
// [error] ^^^^^^^
//
// With just [A] (invariant), contains works fine.
// It's the + that prevents passing A in.
@main def step2d(): Unit =
println("Uncomment each block and read the error messages")
不変の Box[A] では var value: A も contains(item: A) も安全だ。
Box[Book] は Box[Item] ではないから、
サブタイプ関係で型が広がることがなく、互換性のない値が入り込む余地がない。
Box[+A] と宣言すると、Scala は Box[Book] を Box[Item] として見ることを許す。
その広い視点から見ると:
var value: A― 実際にはBox[Book]なのにDVDを代入できてしまうcontains(DVD(...))が有効に見える ―DVDはItemだから
だが実体は Box[Book] のままだ。型安全性を守るため、Scala は両方を拒否する:
var value: A→ 「不変位置(invariant position)」 ―varは読み書き両方contains(item: A)→ 「反変位置(contravariant position)」 ― パラメータは値を消費する
共変型パラメータ(+A)は出力位置にしか現れられない:
val value: A- メソッドの戻り値型
なぜ
containsは無害に見えるのに拒否される? Scala は変位ルールを構造的に強制する ― メソッドの実装は分析しない。containsはこの場合は無害だろうが、var value: Aのようなメンバーは不健全だ。 ルールは統一的:共変型パラメータ(+A)は入力位置に現れられない。
では、共変を維持しつつ有用な入力を諦めずに済む方法は? 型境界の出番だ。
2-5. 下限境界 ― 変位制約からの脱出
2-4 で、+A は A を入力位置で拒否することを見た。
B >: A は「B は A のスーパータイプ」― B は A かその上の型でなければならない。
コンパイラはこう言っている:
「+A と宣言したから、A を入力として受け取ることは許可できない ― 共変が壊れる。
だが A のスーパータイプ B を示してくれれば、既にある中身と追加するものの
両方に合う最も狭い型を見つける。
既に合っていれば B は A のままで何も変わらない。そうでなければカートは
広がる ― 狭まることはない。」
// step2e.scala
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double) extends Item:
override def toString = s"Book($name, $$${price})"
class DVD(val name: String, val price: Double) extends Item:
override def toString = s"DVD($name, $$${price})"
// An immutable shopping cart. +A makes it covariant.
class Cart[+A](private val items: List[A] = Nil):
// def add(item: A) won't compile with +A. Try it!
// def add[B >: A](item: B) compiles — B must be A or wider,
// so the cart can only stay the same or widen, never narrow:
// add(Book) to Cart[Book] → still a cart of Books → Cart[Book]
// add(DVD) to Cart[Book] → no longer just Books → Cart[Item]
def add[B >: A](item: B): Cart[B] = Cart(item :: items)
// A is whatever this cart holds — Book for Cart[Book], Item for Cart[Item].
def total(f: A => Double): Double = items.map(f).sum
override def toString = s"Cart(${items.mkString(", ")})"
@main def step2e(): Unit =
// Cart() is Cart[Nothing] — an empty cart with no type yet.
// add(Book(...)) widens Nothing to Book → Cart[Book].
val bookCart: Cart[Book] = Cart().add(Book("Scala in Depth", 45.0))
println(bookCart) // Cart(Book(Scala in Depth, $45.0))
// Add another Book → stays Cart[Book]
val bookCart2: Cart[Book] = bookCart.add(Book("FP in Scala", 35.0))
println(bookCart2) // Cart(Book(FP in Scala, $35.0), Book(Scala in Depth, $45.0))
// Add DVD → widens to Cart[Item] (common parent of Book and DVD)
val mixedCart: Cart[Item] = bookCart.add(DVD("The Matrix", 19.99))
println(mixedCart) // Cart(DVD(The Matrix, $19.99), Book(Scala in Depth, $45.0))
// The type widened: mixedCart is Cart[Item], not Cart[Book].
// A is now Item, so total's signature became:
// total(f: Item => Double)
// Item has price, so _.price just works:
val subtotal = mixedCart.total(_.price)
val discounted = mixedCart.total(_.price * 0.95)
println(f"Subtotal: $$$subtotal%.2f") // Subtotal: $64.99
println(f"With 5%% off: $$$discounted%.2f") // With 5% off: $61.74
これはまさに List[+A] の prepended がやっていることだ:
// List の簡略化された定義
sealed abstract class List[+A]:
def prepended[B >: A](elem: B): List[B]
List[Book] に DVD を prepend すると List[Item] になる。型は嘘をつかない。
下限境界はどこで使われるか?
「下限境界は基本的に同じような場面で使われるのか?」と思うかもしれない。
そうだ ― いくつかの役割に集中している:
コンテナの拡大 ― 共変コレクションへの挿入:
val books: List[Book] = List(Book("Scala", 45.0))
val items: List[Item] = books :+ DVD("The Matrix", 19.99)
// List[Book] が List[Item] に拡大
フォールバック値 ― Option.getOrElse[B >: A](default: => B): B:
val maybeBook: Option[Book] = None
val item: Item = maybeBook.getOrElse(DVD("The Matrix", 19.99))
// Option[Book] → Book がない → DVD にフォールバック → 結果は Item
結合/マージ ― Either の orElse は Right 型を拡大する:
val result: Either[String, Book] = Left("not found")
val recovered: Either[String, Item] = result.orElse(Right(DVD("The Matrix", 19.99)))
// Either[String, Book] → DVD で復旧 → Either[String, Item]
いずれの場合もパターンは同じだ:[B >: A] は A にぴったり合わない値を受け入れるために型を拡大する。コンパイラは機能する最も狭い型を選ぶ ―
Any ではなく Item。これをLUB(Least Upper Bound、最小上界)と呼ぶ。
名前のついた共通のスーパータイプがない場合、Scala 3 はユニオン型を使う。
日常のアプリケーションコードで [B >: A] を書くことはないだろう ― だが基盤の API は
本当にこれに依存している。下限境界がなければ、共変コンテナは「追加」メソッドを持てない。
なぜ拡大は安全か? コンパイラはこう言っている:
「許可する ― すべての要素は少なくとも Item のメソッドを持っているから、
操作は安全だ。ただし、中身が具体的に何かはもう約束できない。
Item に拡大するよう求めたから、Item だけを保証する。」
型を混ぜる能力を得る代わりに、特定の型を仮定する権利を失う。
[B >: A] はそのトレードオフが本当に必要なときに使おう。予防策としてではなく。
2-6. 上限境界 ― 最低限の振る舞いを要求する
下限境界が拡大するなら、上限境界は制限する。
// step2f.scala
trait Item:
def name: String
def price: Double
trait Shippable extends Item:
def weight: Double
def shippingCost: Double = weight * 0.5
case class Book(name: String, price: Double, weight: Double) extends Shippable
case class DVD(name: String, price: Double, weight: Double) extends Shippable
case class DigitalGiftCertificate(name: String, price: Double) extends Item // Not shippable
// Only Shippable items can be sorted by shipping cost
def sortByShipping[A <: Shippable](items: List[A]): List[A] =
items.sortBy(_.shippingCost)
// Combining covariance with an upper bound:
// +A means Shipment[Book] <: Shipment[Shippable]
// <: Shippable means every item is guaranteed to have shippingCost
class Shipment[+A <: Shippable](private val items: List[A]):
def totalShippingCost: Double = items.map(_.shippingCost).sum
def sortByShipping: List[A] = items.sortBy(_.shippingCost)
// +A forbids A in input position, so we use [B >: A] — same escape hatch as List's :+
def add[B >: A <: Shippable](item: B): Shipment[B] = Shipment(items :+ item)
@main def step2f(): Unit =
// Mix of Book and DVD → List[Shippable], A = Shippable
val items = List(
Book("Scala in Depth", 45.0, 0.8),
DVD("The Matrix", 19.99, 0.2),
)
sortByShipping(items).foreach(p => println(s"${p.name}: shipping=${p.shippingCost}"))
// Try sorting DigitalGiftCertificate?
//val cards = List(DigitalGiftCertificate("Amazon $50", 50.0))
//sortByShipping(cards)
// [error] Found: (cards : List[DigitalGiftCertificate])
// [error] Required: List[Shippable]
// [error] sortByShipping(cards)
// [error] ^^^^^
// Upper bound + covariance on a class
val shipment = Shipment(List(Book("Scala in Depth", 45.0, 0.8)))
println(s"Total shipping: ${shipment.totalShippingCost}")
// add uses [B >: A <: Shippable] — widens from Shipment[Book] to Shipment[Shippable]
val wider = shipment.add(DVD("The Matrix", 19.99, 0.2))
println(s"Total shipping after add: ${wider.totalShippingCost}")
// Try adding a DigitalGiftCertificate to a Shipment?
//val shipmentFails = Shipment(List(DigitalGiftCertificate("Netflix", 20.00)))
// [error] Found: DigitalGiftCertificate
// [error] Required: Shippable
// [error] val shipmentFails = Shipment(List(DigitalGiftCertificate("Netflix", 20.00)))
// [error] ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
上限境界はどこで使われるか?
下限境界と同様に、いくつかの役割に集中している:
特定の振る舞いを要求 ― 特定のメソッドを持つ型のみ受け入れる:
def sortByShipping[A <: Shippable](items: List[A]): List[A] =
items.sortBy(_.shippingCost)
// Shippable なアイテムのみ動作 — DigitalGiftCertificate はコンパイル時に拒否
[A <: Shippable] はコンパイラに「Shippable を拡張する型だけ受け入れろ」と伝える。
関数内部で _.shippingCost を呼べるのは、コンパイラがすべての A に
そのメソッドがあると知っているからだ。DigitalGiftCertificate は Item を拡張するが
Shippable は拡張しない ― だからコンパイラはコード実行前に拒否する。
クラスの型パラメータを制約 ― コンテナが適切な型のみ保持することを保証:
class Shipment[+A <: Shippable](items: List[A]):
def totalShippingCost: Double = items.map(_.shippingCost).sum
def add[B >: A <: Shippable](item: B): Shipment[B] = Shipment(items :+ item)
// Shipment[DigitalGiftCertificate] は作れない — Shippable ではない
+ は 2-4 で見た共変と同じだ ― 境界と一緒に機能する。<: Shippable が許可される型を制限し、+ が
Shipment[Book] <: Shipment[Shippable] を可能にする。
add メソッドは [B >: A <: Shippable] を使う ― 2-5 で見た [B >: A] 脱出口に
<: Shippable を組み合わせてクラスの境界内に留まる。
Shipment[Book] に DVD を追加すると Shipment[Shippable] に拡大する。
なぜ二つの境界が必要なのか?
Bがクラスから<: Shippable境界を継承すると期待するかもしれない ― 結局Aはすでに制約されている。だがBはメソッドで導入された 独自の型パラメータだ。伝えたことしか知らない。そして
B >: Aは階層を上に向かう。Scala ではすべての型の上にAnyRefとAnyがあるので、上限がなければBはどこまでも拡大できる:Book → Shippable → Item → AnyRef → Any。<: Shippableが 「ここで止まれ」と言う。これがなければShipment[B]はコンパイルできない ―Shipmentは型パラメータが<: Shippableであることを要求する。
型情報の保持 ― 計算を通じて具体的なサブタイプを維持する:
def cheapest[A <: Item](items: List[A]): A =
items.minBy(_.price)
val books: List[Book] = List(Book("Scala", 45.0), Book("FP", 35.0))
val b: Book = cheapest(books) // Book を返す、Item ではなく
val dvds: List[DVD] = List(DVD("The Matrix", 19.99), DVD("Inception", 24.99))
val d: DVD = cheapest(dvds) // DVD を返す、Item ではなく
val cards: List[DigitalGiftCertificate] = List(DigitalGiftCertificate("$50", 50.0))
val c: DigitalGiftCertificate = cheapest(cards) // DigitalGiftCertificate を返す、Item ではなく
境界がなければ def cheapest(items: List[Item]): Item と書くしかなく、リストが Book や DVD を含んでいても
すべての呼び出しが Item を返す。振る舞いは保持できるが、具体的な戻り値型を失う。
def cheapest[A](items: List[A]): A とも書けない。minBy(_.price) は
各要素が .price を持つことを要求するからだ。境界がなければ、
コンパイラには A がそれを持つと信じる理由がない。
上限境界は両方を実現する:Item の API へのアクセスと、正確な要素型の返却
(Book を入れれば Book が出る、DVD を入れれば DVD が出る)。つまり <: Item は
呼び出し側を制限するためではなく、どの操作が有効かをコンパイラに伝えつつ
結果型を最大限に具体的に保つためにある。
Java から来た方へ
Java のジェネリクスはデフォルトで不変だ。List<Book> を List<Item> として
受け取るには、呼び出し側で毎回 ? extends を書く必要がある:
// Step2kCov.java — Java's use-site covariance: ? extends
import java.util.List;
class Item {
String name;
double price;
Item(String name, double price) { this.name = name; this.price = price; }
public String toString() { return name + " ($" + price + ")"; }
}
class Book extends Item {
Book(String name, double price, String isbn) { super(name, price); }
}
class DVD extends Item {
DVD(String name, double price) { super(name, price); }
}
public class Step2kCov {
// In Scala: def cheapest[A <: Item](items: List[A]): A
// List[+A] is covariant — List[Book] IS a List[Item].
//
// In Java: List<Book> is NOT List<Item> — generics are invariant.
// To accept a List of any Item subtype, you write ? extends EVERY TIME:
static Item cheapest(List<? extends Item> items) {
Item min = items.get(0);
for (Item item : items) {
if (item.price < min.price) min = item;
}
return min;
// items.add(new DVD("X", 1.0)); // Compile error! Can't add to ? extends
}
public static void main(String[] args) {
List<Book> books = List.of(
new Book("Scala", 45.0, "978-1"),
new Book("FP", 35.0, "978-2")
);
System.out.println("Cheapest book: " + cheapest(books));
List<DVD> dvds = List.of(
new DVD("The Matrix", 19.99),
new DVD("Inception", 24.99)
);
System.out.println("Cheapest DVD: " + cheapest(dvds));
// Without ? extends, this would NOT compile:
// static Item cheapest(List<Item> items) — only accepts List<Item>, not List<Book>
}
}
試してみよう: javac Step2kCov.java && java Step2kCov
これは使用側変位だ ― ? extends を書き忘れると、サブタイプを黙って
受け付けなくなる。柔軟にしたいメソッドすべてにアノテーションが必要で、
書き忘れても何も警告されない。
Scala の List[+A] はクラス定義で共変を一度だけ宣言する ―
cheapest(books) はどこでもそのまま動く。忘れようがない。
標準ライブラリ ― WeakReference[+T <: AnyRef] は参照型のみ受け入れる:
import scala.ref.WeakReference
val ref = WeakReference(List(1, 2, 3)) // OK: List は AnyRef
// WeakReference(42) — 動かない: Int は AnyRef ではない
通常の参照(val x = ...)はオブジェクトを生かし続ける ― 誰かが指している限り
ガベージコレクタは回収しない。WeakReference は違う:GC による回収を
妨げずに参照を保持する。メモリが逼迫すると GC はオブジェクトを回収でき、
弱参照は静かに空になる。
最も一般的な用途はキャッシュだ。計算結果を再利用のために保持したいが、 メモリ不足のコストは払いたくない:
import scala.ref.WeakReference
class Cache[T <: AnyRef](compute: () => T):
private var ref: WeakReference[T] = WeakReference(null)
def get(): T = ref.get match
case Some(value) => value // まだメモリにある、再利用
case None => // GC が回収した、再計算
val value = compute()
ref = WeakReference(value)
value
val users = Cache(() => List("alice", "bob", "charlie"))
users.get() // 初回は計算、GC が回収していなければ再利用
これはヒープ割り当てオブジェクト(AnyRef)でのみ意味がある。Int や Double のような
値型はスタックに直接格納されるか、オブジェクトにインライン化される ― GC が追跡・
回収するヒープオブジェクトがない。上限境界 T <: AnyRef はこの JVM の現実を
型レベルでエンコードしている:ヒープ上に存在しないものへの弱参照は文字通り作れない。
高度に感じても心配ない ― この例はスキップして後で戻ってきても大丈夫だ。 上限境界の巧妙な使い方であり、続きの前提条件ではない。
下限境界は拡大する ― より広い型を受け入れる。上限境界は制限する ― 必要な振る舞いを持たない型を拒否する。
出力側の全ツールキットが揃った: 安全な拡大のための共変(
+A)、 共変を壊さずにメソッドを追加するための下限境界(>:)、 特定の振る舞いを要求するための上限境界(<:)。次は入力側だ。
2-7. 反変 ― 消費者の互換性
最後に -A。共変は出力側 ― プロデューサーの話だった。
反変は入力側 ― コンシューマーの話だ。
// step2g.scala
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double, val isbn: String) extends Item:
override def toString = s"Book($name)"
class DVD(val name: String, val price: Double) extends Item:
override def toString = s"DVD($name)"
// PriceFormatter[-A]: the - declares it contravariant.
// format CONSUMES an A — it takes it in and reads from it.
trait PriceFormatter[-A]:
def format(item: A): String
// Can format ANY Item — only reads name and price
class ItemFormatter extends PriceFormatter[Item]:
def format(item: Item): String = s"${item.name}: $$${item.price}"
// Can only format Books — reads isbn, which only Book has
class BookFormatter extends PriceFormatter[Book]:
def format(item: Book): String = s"${item.name} (ISBN: ${item.isbn}): $$${item.price}"
@main def step2g(): Unit =
val book = Book("Scala in Depth", 45.0, "978-1617295")
// ItemFormatter consumes a Book by reading name and price.
// It doesn't know about isbn — but that's fine, it never asks for it.
// Safe: Book has everything Item has.
val formatterForBook: PriceFormatter[Book] = ItemFormatter()
println(formatterForBook.format(book))
// prints: Scala in Depth: $45.0 — isbn is there, but the formatter never reads it
// BookFormatter consumes a Book by reading name, price, AND isbn.
val bookFormatter: PriceFormatter[Book] = BookFormatter()
println(bookFormatter.format(book))
// prints: Scala in Depth (ISBN: 978-1617295): $45.0
// Now imagine the reverse: using BookFormatter as PriceFormatter[Item].
// val formatterForItem: PriceFormatter[Item] = bookFormatter // Compile error!
//
// If this compiled, you could write:
// formatterForItem.format(DVD("The Matrix", 19.99))
//
// BookFormatter.format reads item.isbn — but DVD has no isbn.
// The compiler catches this: a Book consumer can't safely consume all Items.
多くのチュートリアルは反変を
val bookFmt: Formatter[Book] = itemFormatter のような明示的な代入で説明する ―
上の例のように。サブタイプ関係は見えるが、こんなコードはめったに書かない。
反変を使っている ― 気づかずに ― のは、スーパータイプの関数を渡す
.filter、.map、.sortBy の呼び出しすべてだ。
Function1[-A, +B] ― A => B のデシュガー形式 ― は
標準ライブラリで最も一般的な反変型だ。
A は入力位置 ―
関数はそれを受け取る ― だから反変(-A)。
B は出力位置 ―
関数はそれを返す ― だから共変(+B)。
だから Item => Boolean は Book => Boolean が期待される場所で使える:
// step2g1.scala — Contravariance in Function1
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double, val isbn: String) extends Item:
override def toString = s"Book($name, $$${price})"
class DVD(val name: String, val price: Double) extends Item:
override def toString = s"DVD($name, $$${price})"
@main def step2g1(): Unit =
val books = List(Book("Scala", 45.0, "978-1"), Book("FP", 35.0, "978-2"))
// These functions accept any Item.
// Item => Boolean is shorthand for Function1[Item, Boolean] (-A = Item, +B = Boolean)
val cheap: Item => Boolean = _.price < 40.0
val priceOf: Item => Double = _.price
// List[Book].filter signature: filter(p: Book => Boolean): List[Book]
// Desugar the arrow: filter(p: Function1[Book, Boolean]): List[Book]
//
// You pass cheap, which is Function1[Item, Boolean] — not Function1[Book, Boolean].
// Why does this compile? Function1[-A, +B] is contravariant in A:
// Function1[Item, Boolean] <: Function1[Book, Boolean]
// An Item function only reads name and price — a Book has both, so it's safe.
println(books.filter(cheap)) // List(Book(FP, $35.0))
println(books.sortBy(priceOf)) // List(Book(FP, $35.0), Book(Scala, $45.0))
// The payoff: write once against Item, reuse with any subtype collection
val dvds = List(DVD("The Matrix", 19.99), DVD("Inception", 24.99))
val items: List[Item] = books ++ dvds
println(dvds.filter(cheap)) // List(DVD(The Matrix, $19.99), DVD(Inception, $24.99))
println(items.sortBy(priceOf)) // all four, sorted by price
// The reverse doesn't work: a Book-only function can't handle DVDs
val isbnOf: Book => String = _.isbn
// dvds.map(isbnOf) // Compile error! DVD has no isbn
// items.map(isbnOf) // Compile error! Item has no isbn
パターン:型が A を消費する(入力として受け取る)なら -A の候補。
A を生産する(出力として返す)なら +A の候補。
なぜ方向が逆転するのか? val cheap: Item => Boolean = _.price < 40.0 を見てほしい。
Item のすべてのサブタイプ ― Book、DVD、DigitalGiftCertificate ― は .price を持つ。
だから Item のプロパティだけを使う関数は、どれに対しても自然に動作する。
一度書けば List[Book]、List[DVD]、List[Item] で再利用できる。
反変が実践で意味すること:より広い型のコンシューマーは、 より狭い型のコンシューマーの安全な代替品だ。
最初の例の PriceFormatter は同じアイデアを明示的にしたものだ。
ItemFormatter は name と price だけを使う ― すべての Item が持つもの。だから
Book でも DVD でも何でも扱える。BookFormatter は isbn を使う ―
Book だけが持つもの。DVD を渡すと壊れる。だから逆転:
Book <: Item、しかし PriceFormatter[Item] <: PriceFormatter[Book]。
Java から来た方へ
Java の Predicate<Item> が List<Book> をフィルタできるのは、filter() が
明示的に Predicate<? super T> を受け取るからだ ― 使用側反変:
// Step2kCon.java — Java's use-site contravariance: ? super
import java.util.List;
import java.util.ArrayList;
import java.util.function.Predicate;
class Item {
String name;
double price;
Item(String name, double price) { this.name = name; this.price = price; }
public String toString() { return name + " ($" + price + ")"; }
}
class Book extends Item {
Book(String name, double price) { super(name, price); }
}
class DVD extends Item {
DVD(String name, double price) { super(name, price); }
}
public class Step2kCon {
// In Scala: Function1[-A, +B] — a function on Item works where Book is expected.
// The class declares -A, and it just works everywhere.
//
// In Java: Predicate<Item> is NOT Predicate<Book> — generics are invariant.
// But Predicate declares @FunctionalInterface with ? super,
// so filter() accepts Predicate<? super Book>.
// Use-site contravariance: ? super Book (write-only — like Scala's -A)
static void addBooks(List<? super Book> target) {
target.add(new Book("Scala", 45.0));
target.add(new Book("FP", 35.0));
// Book b = target.get(0); // Compile error! Can only get Object
}
public static void main(String[] args) {
// --- ? super: contravariance at the call site ---
List<Item> items = new ArrayList<>();
addBooks(items); // List<Item> → List<? super Book>
System.out.println("Items: " + items);
// --- Predicate: contravariant in its input ---
List<Book> books = List.of(
new Book("Scala", 45.0),
new Book("FP", 35.0)
);
Predicate<Item> cheap = item -> item.price < 40.0;
long count = books.stream().filter(cheap).count();
System.out.println("Cheap books: " + count);
// In Scala this is just: books.filter(_.price < 40.0)
// Function1[-A, +B] makes Item => Boolean usable as Book => Boolean.
}
}
試してみよう: javac Step2kCon.java && java Step2kCon
もし filter() が ? super なしの filter(Predicate<T>) と書かれていたら、
Predicate<Item> を Predicate<Book> が期待される場所に渡せない ―
しかもアノテーションが抜けていても何も警告されない。
Scala の Function1[-A, +B] は反変を一度だけ宣言する ―
books.filter(cheap) はそのまま動く。呼び出し側は何も意識しない。
反変の場合の境界は?
共変ではコンパイラがこう言った:「+A と宣言したから、A を入力として
受け取ることは許可できない。」 下限境界([B >: A])が脱出口だった ― 上に向かって拡大。
鏡像もある:-A は A を出力位置で禁止し、上限境界([B <: A])が
脱出口だ ― 下に向かって制限。対応関係は固定されている:
| 変位 | 問題 | 脱出口 | 方向 |
|---|---|---|---|
+A 共変 | A が入力に現れない | [B >: A] 下限境界 | 上に拡大 |
-A 反変 | A が出力に現れない | [B <: A] 上限境界 | 下に制限 |
下限境界は共変の問題を解決する。上限境界は反変の問題を解決する。 交差しない ― 共変は拡大するから脱出口も拡大、反変は制限するから脱出口も制限。
だが脱出口ではなく、クラス自体の境界 ― [+A <: Item] や [-A >: Item] ―
はどうか? 有用なのは片方だけだ:
| 共変 | 反変 | |
|---|---|---|
| 脱出口 | [B >: A] 下限境界 | [B <: A] 上限境界 |
| クラス境界 | [+A <: Item] — 有用 | [-A >: Item] — コードスメル |
[+A <: Item] は働きに見合う:クラス内部で A に対して Item のメソッド
― .name、.price ― を呼べる。WeakReference[+T <: AnyRef] や
Shipment[+A <: Shippable] ですでに見た。
[-A >: Item] は冗長だ。PriceFormatter[Book] の存在を防ぐが、
反変がすでに PriceFormatter[Book] を PriceFormatter[Item] が期待される場所で
使えなくしている ― 境界は反変がすでに使用側で処理していることを定義側で繰り返しているだけだ。
コードで見る:[-A >: Item]
/*
step2crossed.scala — Why [-A >: X] is (usually) a code smell
The pairings are fixed:
+A pairs with >: (lower bound)
-A pairs with <: (upper bound)
What happens if you cross them?
*/
trait Item:
def name: String
def price: Double
trait Book extends Item
case class Comic(name: String, price: Double) extends Book
case class Novel(name: String, price: Double) extends Book
case class DVD(name: String, price: Double) extends Item
/*
Item
/ \
Book DVD
/ \
Comic Novel
*/
// ------------------------------------------------------------
// Plain contravariant consumer
// ------------------------------------------------------------
trait PriceFormatter[-A]:
def format(a: A): String
def printPrices(items: List[Item], fmt: PriceFormatter[Item]): Unit =
items.foreach(i => println(fmt.format(i)))
// A narrower formatter
val bookFmt: PriceFormatter[Book] =
b => s"${b.name}: $$${b.price}"
// This does NOT compile:
//
// printPrices(items, bookFmt)
//
// Why?
/*
Type hierarchy:
Comic <: Book <: Item <: Any
Contravariance flips it:
PriceFormatter[Any]
<: PriceFormatter[Item]
<: PriceFormatter[Book]
<: PriceFormatter[Comic]
printPrices requires PriceFormatter[Item].
Only types to the LEFT (subtypes) are acceptable.
PriceFormatter[Book] is to the RIGHT.
So the compiler rejects it — already.
*/
// A wider formatter DOES work:
val anyFmt: PriceFormatter[Any] =
_.toString
// printPrices(items, anyFmt) // compiles
// ------------------------------------------------------------
// The crossed lower bound: [-A >: Item]
// ------------------------------------------------------------
// What if we added >: Item?
//
// trait PriceFormatter[-A >: Item]:
// def format(a: A): String
//
// It rejects things like:
//
// val stringFmt: PriceFormatter[String] = _.toUpperCase // rejected
//
// But even without the bound, you can't pass PriceFormatter[String]
// to printPrices — contravariance already blocks it.
// The bound prevents creating something you couldn't use anyway.
// ------------------------------------------------------------
// Takeaway
// ------------------------------------------------------------
/*
For a pure consumer type (-A), adding A >: Item
does not improve substitutability safety when
your APIs already require PriceFormatter[Item].
Variance enforces the safety property.
The bound merely narrows the universe of
possible instantiations.
If a constraint doesn't reject anything relevant
to how the type is actually used,
it's not protection — it's noise.
*/
val itemFmt: PriceFormatter[Item] =
a => s"${a.name}: $$${a.price}"
@main def step2crossed(): Unit =
val items = List(
Comic("Watchmen", 25.0),
Novel("Dune", 18.0),
DVD("The Matrix", 19.99)
)
printPrices(items, itemFmt)
実例:ZIO
PriceFormatter[-A] や Function1[-A, +B] のような単純なコンシューマーでは、
反変の脱出口は問題にならない ― String や Boolean を返すだけで、A は返さないからだ。
だが反変型を合成するときには問題になる ― 実際の例で見るのが
脱出口の存在意義を理解する最良の方法だ。
ZIO(最も人気のある Scala ライブラリの一つ)は依存関係管理に 興味深いアプローチを取る:依存関係(データベース接続、ロガー)を関数に直接渡す 代わりに、必要なものを型で記述する。プログラムは一つの値になる: 「Database があれば実行でき、String を生産する。」 コンパイラがプログラム実行前にすべての依存関係が満たされていることを検証する ― 型レベルの依存性注入だ。
ZIO はこれを ZIO[-R, +E, +A] としてエンコードする:
-R(環境) ― プログラムが実行に必要とするもの。ここまで見てきたとおり反変。+E(エラー) ― プログラムがどう失敗しうるか。共変 ―DatabaseErrorを処理する関数は、NetworkErrorで失敗するコードと合成しても 動作し、DatabaseError | NetworkErrorに拡大する。+A(値) ― 成功時にプログラムが生産するもの。共変。
型付きエラーチャネルは ZIO がプロダクションで有用な大きな理由だ:
Exception を catch して最善を祈る代わりに、コンパイラが各操作のエラーを
正確に教え、処理を強制する。変位がこれを合成可能にする:
異なる失敗をする二つの操作を組み合わせると、エラー型のユニオンが得られる。
簡略版 Effect[-R, +A] を作る ― 変位と上限境界に集中するためエラーチャネルは
省く。この Effect は反変の脱出口がなぜ必要かを見るための教材にすぎない:
R1 <: R なしでは flatMap が書けず、flatMap なしではエフェクトを合成できない。
Effect[-R, +A] は環境 R を必要として実行し、値 A を生産する計算だ。
内部的には関数 R => A をラップしている:
class Effect[-R, +A](val run: R => A)
R => A は Function1[R, A] であり、R は -A スロット、A は +B
スロットにある。つまり Effect[-R, +A] は Function1[-A, +B] と同じ変位の
形だ ― ただのラッパーなのだから当然だ。
Effect[-R, +A] が実際にどう動くか見るために、異なる機能を表す2つのトレイトと、
両方を提供する AppEnv を定義しよう:
trait Database:
def lookup(id: Int): String
trait Logger:
def log(msg: String): Unit
class AppEnv extends Database, Logger:
def lookup(id: Int) = s"user-$id"
def log(msg: String) = println(s" LOG: $msg")
Database Logger
↑ ↑
└── AppEnv ──────┘
Database を受け取り String を返すエフェクトが欲しい ― そこで
db => db.lookup(1) を渡すと、型は Effect[Database, String] になる。
logMsg は String を受け取り、Logger を必要とするエフェクトを返す ―
メッセージをクロージャで閉じ込める:
val fetchUser: Effect[Database, String] = Effect(db => db.lookup(1))
val logMsg: String => Effect[Logger, Unit] = msg => Effect(logger => logger.log(msg))
for 内包表記で合成すると、Database & Logger を必要とするエフェクトになる
― 環境を渡して実行する:
val program: Effect[Database & Logger, Unit] =
for
user <- fetchUser
_ <- logMsg(s"fetched $user")
yield ()
program.run(AppEnv()) // LOG: fetched user-1
for 内包表記は flatMap と map にデシュガーされる ― だが flatMap の
定義こそ、変位が立ちはだかるところだ。
問題:flatMap と -R
直感的には flatMap はクラスの R をそのまま使うはずだ ―
現在のエフェクトも次のエフェクトも同じ環境で:
def flatMap[B](f: A => Effect[R, B]): Effect[R, B]
だがコンパイラはこれを拒否する。理由を見るには、コンパイラが位置をどう 読むかを知る必要がある:
- メソッドパラメータは入力 ― 位置は
(-) - 戻り値型は出力 ― 位置は
(+) - 型がネストされると、位置は符号のように掛け合わされる:
(+) × (+) = (+) 出力の中の出力 → そのまま出力
(+) × (-) = (-) 入力の中の出力 → 入力に反転
(-) × (-) = (+) 入力の中の入力 → 出力に戻る
シグネチャをデシュガー:flatMap[B](f: Function1[A, Effect[R, B]]): Effect[R, B]
| ステップ | R はどこ? | 位置 |
|---|---|---|
f はメソッドパラメータ | 開始 | (-) |
Effect[R, B] は Function1 の +B スロット | (-) × (+) | (-) |
R は Effect の -R スロット | (-) × (-) | (+) |
結果:R は (+) に着地。-R と宣言した。拒否。
修正:メソッドに新しい型パラメータ R1 <: R を導入する。
変位ルール(+ と -)はクラス自身の型パラメータにのみ適用される。
R1 はメソッドの型パラメータなので、変位チェッカーはまったく追跡しない。
どの位置にも現れられる。コンパイラがチェックするのは、呼び出し側で
R1 <: R が成立するかだけだ。
def flatMap[R1 <: R, B](f: A => Effect[R1, B]): Effect[R1, B] =
Effect(r => f(run(r)).run(r))
比較:なぜ List の flatMap は境界なしで動くのか?
デシュガー:flatMap[B](f: Function1[A, IterableOnce[B]]): List[B]
| ステップ | A はどこ? | 位置 |
|---|---|---|
f はメソッドパラメータ | 開始 | (-) |
A は Function1 の -A スロット | (-) × (-) | (+) |
結果:A は (+) に着地。+A と宣言した。OK。 ネストの追加層がないので
位置は反転せず、境界は不要だ。
実装の仕組み
R1 <: R は変位チェッカーのためだけじゃない ― 実装も動作させる。
Effect(r => f(run(r)).run(r)) を名前付きステップに分解し、
Function1[-A, +B] を明示的に書いて各型パラメータの着地位置を見てみよう:
// つまり R => A
class Effect[-R, +A](val run: Function1[R, A]):
// つまり A => Effect[R1, B]
def flatMap[R1 <: R, B](f: Function1[A, Effect[R1, B]]): Effect[R1, B] =
Effect { r => // r: R1 — 戻り値型が Effect[R1, B] だから
val a = run(r) // このエフェクトを実行 — R1 <: R なので OK
val effect2 = f(a) // 次のエフェクトを構築
effect2.run(r) // 同じ環境で実行
}
なぜ r は R ではなく R1 なのか? flatMap は Effect[R1, B] を返すからだ。
これは関数 R1 => B をラップしている。だからその関数に渡される r は型 R1 だ。
そして R1 <: R なので、同じ r が run(r)(R を期待)と
effect2.run(r)(R1 を期待)の両方に使える。
これは反変のパターンだ:run は R を期待し、R1 は R が持つものをすべて
持っている ― より広い型のコンシューマーはより狭い型で動作する。
scala-cli run step2g2.scala で完全な例を実行できる:
// step2g2.scala — Variance in action: a simplified ZIO-like effect
// An effect that requires an environment R and produces a value A.
// This is the core idea behind ZIO[-R, +E, +A] (simplified: no error channel).
class Effect[-R, +A](val run: R => A):
def map[B](f: A => B): Effect[R, B] =
Effect(r => f(run(r)))
// Intuitively, using the R from the class, the signature would be:
// def flatMap[B](f: A => Effect[R, B]): Effect[R, B]
// But the class's -R ends up in a covariant position, so the compiler rejects it.
// (See the chapter text for the full position trace.)
// Fix: introduce R1, a fresh method parameter not subject to variance checking,
// and define it as R's subtype — so R1 provides at least the same environment as R.
def flatMap[R1 <: R, B](f: A => Effect[R1, B]): Effect[R1, B] =
Effect(r => f(run(r)).run(r))
// --- Setup: a small service hierarchy ---
trait Database:
def lookup(id: Int): String
trait Logger:
def log(msg: String): Unit
class AppEnv extends Database, Logger:
def lookup(id: Int): String = s"user-$id"
def log(msg: String): Unit = println(s" LOG: $msg")
// --- Effects with different environment requirements ---
// Needs only a Database
val fetchUser: Effect[Database, String] = Effect { db =>
db.lookup(1)
}
// Needs only a Logger
val logMsg: String => Effect[Logger, Unit] = msg => Effect { logger =>
logger.log(msg)
}
@main def step2g2(): Unit =
val env = AppEnv()
// fetchUser needs Database, logMsg needs Logger
// the for-comprehension combines them — the result needs both
val program: Effect[Database & Logger, Unit] =
for
user <- fetchUser
_ <- logMsg(s"fetched $user")
yield ()
// AppEnv extends Database, Logger — it satisfies both
program.run(env) // LOG: fetched user-1
// A plain Database isn't enough — program also needs a Logger
// val db: Database = new Database { def lookup(id: Int) = s"user-$id" }
// program.run(db) // Compile error — db is not a Logger
2-8. クイックリファレンス
たくさんの要素が出てきた。この章のすべてをまとめる:
不変(デフォルト) → 互換性を仮定しない
+A(共変) → 出力側 — 安全に拡大
-A(反変) → 入力側 — 逆方向で安全
>:(下限境界) → 共変型の脱出口(拡大)
<:(上限境界) → 反変型の脱出口(制限)
だが仕組みを知るのは話の半分だ。次の問いは:
実際にどのくらい不変が欲しくて、いつ + や - を使うべきか?
一息つこう。 難しい概念的な作業は終わった。Step 3 では変位が実際のコードで どう働くかを見る ― より短く、より具体的だ。
Step 3: 変位の実践
3-1. 共変の実践 ― すでに使っている
共変を見るのにカスタム型は要らない ― 毎日使う標準ライブラリの型に入っている。
どれも 2-5 で見た [B >: A] 下限境界の脱出口を示している:
// step2h.scala — Covariance in the standard library
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double, val isbn: String) extends Item:
override def toString = s"Book($name)"
class DVD(val name: String, val price: Double) extends Item:
override def toString = s"DVD($name)"
@main def step2h(): Unit =
val book = Book("Scala in Depth", 45.0, "978-1617295")
val dvd = DVD("The Matrix", 19.99)
// --- Option[+A] ---
// Some[Book] is usable as Option[Item] — a Book result IS an Item result
val maybeBook: Option[Book] = Some(book)
val maybeItem: Option[Item] = maybeBook // covariance
println(maybeItem) // Some(Book(Scala in Depth))
// getOrElse uses [B >: A] — the lower bound escape hatch for covariance
val item: Item = maybeBook.getOrElse(dvd) // Option[Book] → fallback DVD → Item
println(item)
// --- List[+A] ---
// List[Book] is usable as List[Item]
val books: List[Book] = List(book)
val items: List[Item] = books // covariance
println(items)
// :+ uses [B >: A] — adding a DVD widens to List[Item]
val mixed: List[Item] = books :+ dvd
println(mixed) // List(Book(Scala in Depth), DVD(The Matrix))
// --- Nothing ---
// Nothing is Scala's bottom type — a subtype of every other type.
// No value of type Nothing can ever exist, but the compiler uses it as a
// placeholder: "this side has no type yet." Combined with covariance,
// Nothing <: anything, so it fits wherever a type is expected.
// --- Either[+A, +B] ---
// Either is covariant in BOTH type parameters
// Right(book) is Right[Nothing, Book] — Nothing is the Left type.
// Covariance on +A: Nothing <: String, so Either[Nothing, Book] <: Either[String, Book]
val result: Either[String, Book] = Right(book)
val wider: Either[String, Item] = result // covariance on +B: Book <: Item
println(wider)
// Same idea with Left: Left("not found") is Left[String, Nothing]
// Covariance on +B: Nothing <: Book, so Either[String, Nothing] <: Either[String, Book]
val failed: Either[String, Book] = Left("not found")
println(failed)
// orElse uses [B1 >: B] — recovery widens the Right type
val recovered: Either[String, Item] = failed.orElse(Right(dvd))
println(recovered) // Right(DVD(The Matrix))
このコードは Nothing ― Scala のボトム型を紹介する。すべての型のサブタイプであり、
Nothing 型の値は決して存在できない。
コンパイラは型の片側が未指定のときにプレースホルダーとして使う:
Right(book)はRight[Nothing, Book]― Left 型はまだないLeft("error")はLeft[String, Nothing]― Right 型はまだないList()はList[Nothing]― 空、要素型はまだないNoneはOption[Nothing]― 値がない、型もまだない
共変がこれを機能させる。任意の A に対して Nothing <: A なので、
型が期待される場所にいつでも適合する。2-5 ですでに見た:Cart() は
Cart[Nothing] だ。Book を追加すると [B >: A] によって Cart[Book] に拡大する。
パターンはすべて同じだ:型は A の値を生産(返す、保持する、発行する)するので、
+A が自然だ。異なるサブタイプの値を追加・結合する必要があるときは、
[B >: A] が安全に型を拡大する。
3-2. 反変の実践 ― ハンドラ、バリデータ、シリアライザ
反変はより稀だが、非常に特定のパターンで現れる: 値を消費または処理する型だ。
// step2i.scala
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double, val isbn: String) extends Item:
override def toString = s"Book($name, $price, $isbn)"
class DVD(val name: String, val price: Double) extends Item:
override def toString = s"DVD($name, $price)"
// JSON serializer — consumes a value and produces a String
trait JsonWriter[-A]:
def write(value: A): String
class ItemWriter extends JsonWriter[Item]:
def write(value: Item): String =
s"""{"name":"${value.name}","price":${value.price}}"""
class BookWriter extends JsonWriter[Book]:
def write(value: Book): String =
s"""{"name":"${value.name}","price":${value.price},""" +
s""""isbn":"${value.isbn}"}"""
// Validator — consumes a value and returns pass/fail
trait Validator[-A]:
def validate(value: A): Boolean
class PriceValidator extends Validator[Item]:
def validate(value: Item): Boolean = value.price > 0
def serialize[A](value: A, writer: JsonWriter[A]): String =
writer.write(value)
@main def step2i(): Unit =
val itemWriter = ItemWriter()
val book = Book("Scala in Depth", 45.0, "978-1617295")
// serialize expects JsonWriter[Book] — ItemWriter handles any Item, so it qualifies
println(serialize(book, itemWriter))
// {"name":"Scala in Depth","price":45.0} — no isbn, but it works
// A BookWriter knows about Book-specific fields
val bookWriter = BookWriter()
println(serialize(book, bookWriter))
// {"name":"Scala in Depth","price":45.0,"isbn":"978-1617295"}
// The reverse doesn't work: a BookWriter can't handle arbitrary Items
// serialize(DVD("The Matrix", 19.99), bookWriter) // Compile error
// What would bookWriter do with a DVD's isbn?
// Same pattern with Validator: PriceValidator validates any Item,
// so it works on Books too
val books = List(Book("Scala", 45.0, "978-1"), Book("Free", 0.0, "978-2"))
val valid = books.filter(PriceValidator().validate)
println(valid) // List(Book(Scala, 45.0, 978-1))
2-7 ですでにこのパターンを見た:Function1[-A, +B] は入力に対して反変であり、
cheap が Item => Boolean のとき books.filter(cheap) が動作する理由だ。
ここの JsonWriter は同じアイデアだ。serialize は JsonWriter[Book] を期待し、
ItemWriter(JsonWriter[Item])が適格だ ― 任意の Item をシリアライズできるなら、
Book もシリアライズできる。Validator も同じパターンだ:
任意の Item を検証する PriceValidator は books.filter でも機能する。
3-3. では不変は本当に役立つのか?
役立つ。ただし役割は思うより狭い。
不変は型が読み書き両方するときに正しい選択だ ― 可変コンテナ、 双方向チャネル、読み書き参照。しかし良く設計された Scala コードでは、 不変性が好まれるため比較的稀だ。
// step2j.scala
trait Item:
def name: String
def price: Double
class Book(val name: String, val price: Double, val isbn: String) extends Item:
override def toString = s"Book($name)"
// Invariant is correct here: a mutable cell reads AND writes
class Cell[A](var value: A):
def get: A = value // output position (+)
def set(a: A): Unit = // input position (-)
value = a
// A bidirectional codec: encodes AND decodes
trait Codec[A]:
def encode(value: A): String // A in input position (-)
def decode(raw: String): A // A in output position (+)
// Both positions → must be invariant. The compiler enforces this.
// Try adding + or - and see what happens:
// trait Codec[+A]: // error on encode
// trait Codec[-A]: // error on decode
@main def step2j(): Unit =
val cell = Cell[Item](Book("Scala", 45.0, "978-1"))
cell.set(Book("FP", 35.0, "978-2"))
println(cell.get) // Book(FP)
// Cell[Book] is NOT Cell[Item] — and shouldn't be.
// If it were, you could put a DVD in a Book cell.
実際のところ:
Scala で設計する型のほとんどは二つの陣営のどちらかに入る:
A を生産する(読み取り専用データ、結果、イベント、ストリーム) → 共変にする [+A]
A を消費する(ハンドラ、ライター、バリデータ、順序付け) → 反変にする [-A]
不変はアノテーションしないときに得られるものだ ― そしてそれは多くの場合、
自分の型がプロデューサーなのかコンシューマーなのかまだ考えていないからだ。
考えてみると、+ か - のどちらかが大抵当てはまることに気づくだろう。
「この型は A を生産するのか消費するのか?」と問う規律自体が
貴重な設計エクササイズだ。型の役割を明確にすることを強制し、
コンパイラがその答えを検証してくれる。