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

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 を生産するのか消費するのか?」と問う規律自体が 貴重な設計エクササイズだ。型の役割を明確にすることを強制し、 コンパイラがその答えを検証してくれる。