Swift Digest
SE-0360 | Swift Evolution

Opaque result types with limited availability

Proposal
SE-0360
Authors
Pavel Yaskevich
Review Manager
Joe Groff
Status
Implemented (Swift 5.7)

01 何が問題だったのか

SE-0244 で導入された opaque result type(some P)は、関数の戻り値の「具体的な型」を隠蔽しつつ、呼び出し側には「あるプロトコル P に適合する、何らかの一つの具体型」であることを保証する仕組みです。これを成立させるため、SE-0244 は opaque result type を返す関数に対して「関数本体のすべての return 文が同じ具体型 T を返さなければならない」という強い制約を課していました。

しかし、この「同じ具体型」という制約は、OS バージョンに応じて挙動を切り替えたい状況と相性が悪くなります。具体的には、次のようなライブラリの進化シナリオで行き詰まりが生じます。

ある図形フレームワークに Shape プロトコルと Square 型が既に存在し、新バージョンで Rectangle 型を追加したいとします。Rectangle は新 OS でしか使えないため、@available で可用性を限定する必要があります。

protocol Shape {
  func draw(to: Surface)
}

struct Square: Shape { ... }

@available(macOS 100, *)
struct Rectangle: Shape { ... }

SquareRectangle に変換する asRectangle() メソッドを some Shape を返す形で提供したい場合、現状では次のように書くしかありません。

@available(macOS 100, *)
extension Square {
  func asRectangle() -> some Shape {
    return Rectangle(...)
  }
}

このようにメソッド自体が可用性制限を持ってしまうと、呼び出し側は常に if #available で囲む必要があり、API としての使い勝手が大きく損なわれます。

かといって、古い OS では Square 自身を返し、新しい OS でのみ Rectangle を返すような実装は、SE-0244 のルールのもとでは書けません。

struct Square {
  func asRectangle() -> some Shape {
    if #available(macOS 100, *) {
      return Rectangle(...)
    }
    return self
  }
}

このコードは、「return Rectangle() の具体型は Rectanglereturn self の具体型は Square で一致しない」としてコンパイルエラーになります。

error: function declares an opaque return type 'some Shape', but the return statements in its body do not have matching underlying types

SE-0244 自体は「将来のバージョンで underlying type を差し替えられる」ことを目標に掲げていましたが、その前提は「既にすべての OS バージョンで使える型が用意されている」場合に限られ、新たに可用性制限付きの型を導入したい場面ではこの進化パスが塞がれてしまっていました。

02 どのように解決されるのか

opaque result type を返す関数のうち、if #available による分岐については「同じ具体型を返す」という制約を緩め、分岐ごとに異なる underlying type を返せるようにします。これにより、プログラムの 1 回の実行中は underlying type が一つに定まるという性質を保ったまま、実行環境(OS バージョン)ごとに別の型を返す API を書けるようになります。

次の例はこの新ルールで受け付けられるようになる典型的な形です。

func test() -> some Shape {
  if #available(macOS 100, *) {
    return Rectangle()
  }
  return self
}

macOS 100 以上で動作しているときは underlying type が Rectangle、それ以外では Square となります。どちらの場合でも「一つの実行において underlying type は一つに決まる」ので、opaque result type の抽象モデルは保たれます。

unconditional availability clause

本 Proposal ではこの特別扱いを受けられる if #available 節を、unconditional availability clause(無条件可用性節)として定義しています。ある if / else if 節がこれに該当するのは、次の条件をすべて満たす場合です。

  • その if 文が、関数本体のトップレベルに書かれている。
  • その if 文より前に return 文が存在しない。
  • 節の条件が #available である。
  • 最初の if 節か、または直前の節が unconditional availability clause である else if 節である。
  • 節の中に少なくとも一つ return 文がある。
  • 節のブロックを通過するすべてのパスが、return または throw で終わる。

unconditional availability clause の外にある return 文どうしは、従来どおり同じ具体型を返す必要があり、その型は関数全体と同じ可用性を持たなければなりません。一方、ある unconditional availability clause の中の return 文どうしも同じ具体型でなければなりませんが、その型は節の外の型と一致している必要はなく、節の #available 条件と同じ可用性を持っていれば十分です。

関数全体には少なくとも一つの return 文が必要です。unconditional availability clause の外に一つも return がない場合は、節の中の型のうち少なくとも一つが関数全体と同じ可用性を持たなければなりません。

動的な戻り型は次のように決まります。

  • 最初にマッチした unconditional availability clause の中の return 文の型。
  • どの節もマッチしなければ、節の外の return 文の型。
  • 節の外に return 文が無い場合は、関数と同じ可用性を持つ最初の unconditional availability clause の型。

許容される書き方・されない書き方

else if #available の連鎖も、直前までが unconditional availability clause である限り許されます。

func test() -> some Shape {
  if #available(macOS 100, *) {
    return Rectangle()
  } else if #available(macOS 99, *) {
    return Square()
  }
  return self
}

逆に、#available が「動的条件(通常の ifguardswitch など)と混ざっている」場合や、「#available より前に return する可能性のある動的条件がある」場合、「#available がループの中にある」場合は、この特別扱いを受けられません。これは、関数の戻り型を「実際に本体を実行せずに、静的に一意に決められる」という性質を保つためです。

// NG: 動的条件に続く else if #available
func test() -> some Shape {
  if cond {
    ...
  } else if #available(macOS 100, *) {
    return Rectangle()
  }
  return self
}

// NG: #available より前に動的条件による return がある
func test() -> some Shape {
  guard let x = optValue else {
    return ...
  }
  if #available(macOS 100, *) {
    return Rectangle()
  }
  return self
}

// NG: ループ内の #available
func test() -> some Shape {
  for ... {
    if #available(macOS 100, *) {
      return Rectangle()
    }
  }
  return self
}

また、unconditional availability clause の内部でさらに分岐する場合は、節の中のすべての return が同じ具体型でなければなりません。

// OK: 節内のすべての return が Rectangle
func test() -> some Shape {
  if #available(macOS 100, *) {
    if cond {
      return Rectangle(...)
    } else {
      return Rectangle(...)
    }
  }
  return self
}

// NG: 節内の return の具体型が Rectangle と Square で食い違う
func test() -> some Shape {
  if #available(macOS 100, *) {
    if cond {
      return Rectangle()
    } else {
      return Square()
    }
  }
  return self
}

この仕組みによって、「プラットフォームごと・そして実行単位ごとに underlying type は常に一つに決まる」という opaque result type の基本モデルを壊すことなく、可用性制限付きの新しい型を既存 API にスムーズに取り込めるようになります。