Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

型で設計する ― Scala 3 実践ガイド:基礎から型レベルプログラミングまで

English version

このチュートリアルは 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/)こと。各ステップのファイルは、進行を示すために同じクラス(ProductBookBox など)を異なるシグネチャで意図的に再定義している。まとめてコンパイルすると重複定義エラーになる。

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"

mixedList[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.
    }
}

試してみようjavacjava が必要):

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: Acontains(item: A) も安全だ。 Box[Book]Box[Item] ではないから、 サブタイプ関係で型が広がることがなく、互換性のない値が入り込む余地がない。

Box[+A] と宣言すると、Scala は Box[Book]Box[Item] として見ることを許す。 その広い視点から見ると:

  • var value: A ― 実際には Box[Book] なのに DVD を代入できてしまう
  • contains(DVD(...)) が有効に見える ― DVDItem だから

だが実体は 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 で、+AA を入力位置で拒否することを見た。

B >: A は「BA のスーパータイプ」― BA かその上の型でなければならない。 コンパイラはこう言っている: +A と宣言したから、A を入力として受け取ることは許可できない ― 共変が壊れる。 だが A のスーパータイプ B を示してくれれば、既にある中身と追加するものの 両方に合う最も狭い型を見つける。 既に合っていれば BA のままで何も変わらない。そうでなければカートは 広がる ― 狭まることはない。」

// 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

結合/マージEitherorElseRight 型を拡大する:

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 に そのメソッドがあると知っているからだ。DigitalGiftCertificateItem を拡張するが 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 ではすべての型の上に AnyRefAny があるので、上限がなければ 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 と書くしかなく、リストが BookDVD を含んでいても すべての呼び出しが 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)でのみ意味がある。IntDouble のような 値型はスタックに直接格納されるか、オブジェクトにインライン化される ― 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 => BooleanBook => 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 のすべてのサブタイプ ― BookDVDDigitalGiftCertificate ― は .price を持つ。 だから Item のプロパティだけを使う関数は、どれに対しても自然に動作する。 一度書けば List[Book]List[DVD]List[Item] で再利用できる。

反変が実践で意味すること:より広い型のコンシューマーは、 より狭い型のコンシューマーの安全な代替品だ。

最初の例の PriceFormatter は同じアイデアを明示的にしたものだ。 ItemFormatternameprice だけを使う ― すべての Item が持つもの。だから Book でも DVD でも何でも扱える。BookFormatterisbn を使う ― 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])が脱出口だった ― 上に向かって拡大。

鏡像もある:-AA を出力位置で禁止し、上限境界([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] のような単純なコンシューマーでは、 反変の脱出口は問題にならない ― StringBoolean を返すだけで、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 => AFunction1[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] になる。 logMsgString を受け取り、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 内包表記は flatMapmap にデシュガーされる ― だが 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 スロット(-) × (+)(-)
REffect-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 はメソッドパラメータ開始(-)
AFunction1-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)           // 同じ環境で実行
    }

なぜ rR ではなく R1 なのか? flatMapEffect[R1, B] を返すからだ。 これは関数 R1 => B をラップしている。だからその関数に渡される r は型 R1 だ。 そして R1 <: R なので、同じ rrun(r)R を期待)と effect2.run(r)R1 を期待)の両方に使える。 これは反変のパターンだ:runR を期待し、R1R が持つものをすべて 持っている ― より広い型のコンシューマーはより狭い型で動作する。

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] ― 空、要素型はまだない
  • NoneOption[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] は入力に対して反変であり、 cheapItem => Boolean のとき books.filter(cheap) が動作する理由だ。

ここの JsonWriter は同じアイデアだ。serializeJsonWriter[Book] を期待し、 ItemWriterJsonWriter[Item])が適格だ ― 任意の Item をシリアライズできるなら、 Book もシリアライズできる。Validator も同じパターンだ: 任意の Item を検証する PriceValidatorbooks.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 を生産するのか消費するのか?」と問う規律自体が 貴重な設計エクササイズだ。型の役割を明確にすることを強制し、 コンパイラがその答えを検証してくれる。