この記事は何?
不明瞭型(Opaque Type)について、Appleの開発者向けドキュメントを独自に解説する。
Swiftを基礎から学ぶには
自著、工学社より発売中の「まるごと分かるSwiftプログラミング」をお勧めします。変数、関数、フロー制御構文、データ構造はもちろん、構造体からクロージャ、エクステンション、プロトコル、クロージャまでを基礎からわかりやすく解説しています。
実行環境
- Swift 5.9
- Xcode 15
- macOS 14.0
不明瞭型
値の型について、その実装を隠蔽する方法。
型情報を隠蔽することで、「返り値のデータ型」を気にすることなくモジュールを利用できる。
返り値が不明瞭型の関数またはメソッドは、その返り値の型情報を隠蔽する。
不明瞭型を返す関数では「返り値に具体的な型を指定する」のではなく、その型が「どのプロトコルに適合しているか」を記述する。
不明瞭型自体は「値の具体的な型」を認識している(コンパイラは型情報にアクセスできるが、モジュールのクライアントはアクセスできない)。
不明瞭型が解決できること
ここでは例として、「ASCIIアートを描画するモジュール」の作成する。
モジュールの基本用途は、文字列で表現された「ASCIIアートの基本的な図形」を返すdraw()
関数。
このdraw()
関数はShape
プロトコルの要件になっている。
// +++ モジュール側 +++
// Shapeプロトコルに適合する型は、図形であることを保証する
protocol Shape {
func draw() -> String
}
// 三角形をモデル化した構造体
// draw()メソッドを実装しているので、型はShapeプロトコルに適合している
struct Triangle: Shape {
var size: Int
func draw() -> String {
var result: [String] = []
for length in 1...size {
result.append(String(repeating: "*", count: length))
}
return result.joined(separator: "\n")
}
}
// +++ モジュールを利用するコード +++
// smallTriangle is type of `Triangle`
let smallTriangle = Triangle(size: 3)
print(smallTriangle.draw())
// *
// **
// ***
以下のコードが示すように、実装にジェネリクスを利用すれば、どんな図形でも垂直に反転させることができる。
しかし、この手法には重大な制限がある。
反転した結果、それを作成するために使用した図形のジェネリック型が丸見えになっている。
// +++ モジュール側 +++
// ジェネリクスで実装された、図形の反転
// Shapeプロトコルに準拠していれば、どんな図形でも反転できるジェネリック型
struct FlippedShape<T: Shape>: Shape {
var shape: T
func draw() -> String {
let lines = shape.draw().split(separator: "\n")
return lines.reversed().joined(separator: "\n")
}
}
// +++ ジュールを利用するコード +++
// flippedTriangle is type of FrippedShape<Triangle>
// 型パラメータを見ると、モジュールの呼び出し側から「元の図形が`Triangle`だった」ことがわかる
let flippedTriangle = FlippedShape(shape: smallTriangle)
print(flippedTriangle.draw())
// ***
// **
// *
例えは、以下のコードで定義しているJoinedShape<T:Shape、U:Shape>
型は、2つの図形を垂直に合体する。
この実装で「反転した三角形」を「別の三角形」と結合すると、返り値の型はJoinedShape<Triangle, FlippedShape<Triangle>>
になる。
// 図形の合体をモデル化した構造体
// Shapeプロトコルに適合した図形なら、どんな図形でも合体できるジェネリック型
struct JoinedShape<T: Shape, U: Shape>: Shape {
var top: T
var bottom: U
func draw() -> String {
return top.draw() + "\n" + bottom.draw()
}
}
// +++++ モジュールを利用するコード +++++
// 定数joinedTrianglesはJoinedShape<Triangle, FlippedShape<Triangle>>型
// 型パラメータを見ると、モジュールの呼び出し側から「三角形と反転三角形を合体した」ことがわかる
let joinedTriangles = JoinedShape(top: smallTriangle, bottom: flippedTriangle)
print(joinedTriangles.draw())
// *
// **
// ***
// ***
// **
// *
作成された図形の型が公開されていると、モジュールの利用者はその図形を扱う際に「それがどんな型だったか」を正確に意識する必要がある。
返り値型が丸見えだと、ASCIIアートのモジュールの公開すべきではない型が外部に漏洩する。
モジュール内のコードは、さまざまな方法で同じ図形を構築できる方が良い。
一方で、モジュール外のコードが図形を扱うにあたっては、図形の詳細な型情報を意識させたくない。
JoinedShape
型やFlippedShape
型は「元になった図形」をラップしているに過ぎない。
このようなラッパー型はモジュールのユーザーにとって重要ではないので、公開されるべきではない。
モジュールが公開するインターフェイスは「図形の結合や反転などの操作」で構成することにして、操作結果は「新しいShape
値を返す」のが良い。
不明瞭型を返す
不明瞭型は「逆のジェネリック型」と考えることができる。
ジェネリック関数の呼び出す際は、そのパラメータが「どんな型であるか」を意識せずに値を指定でき、実装から抽象化した方法で値を返すことができる。
例えば、次のmax(_:_:)
関数はジェネリクスを利用しているので、「パラメータと返り値がどんな型になるか」は呼び出し側のコード次第ということ。
func max<T>(_ x: T, _ y: T) -> T where T: Comparable { ... }
max(_:_:)
関数を呼び出す時に指定するx
とy
の値によって、T
の具体的な型が決まる。
x
とy
を指定する際は、「Comparable
プロトコルに適合した型」ならどんな値でも使用できる。
max(_:_:)
関数の実装はジェネリクスを利用しているので、呼び出し側がどんな型を指定しても問題ない。
ただし、max(_:_:)
の実装で使用できる機能は、Comparable
型が共用するものに限られる。
不明瞭型の値を返す関数では、これらの役割は反対になる。
不明瞭型を使用すると、関数の実装は「呼び出し側から抽象化された方法」で返り値の型を宣言できる。
たとえば、次の例のmakeTrapezoid()
関数は「3つの図形を合体した台形」を返すが、その図形の基となった型を公開しない。
// モジュールのコード
// 正方形をモデル化
struct Square: Shape {
var size: Int
func draw() -> String {
let line = String(repeating: "*", count: size)
let result = Array<String>(repeating: line, count: size)
return result.joined(separator: "\n")
}
}
// 台形を作成するための、不明瞭型を返す関数
// 三角形、正方形、反転の三角形を垂直に合体する操作
// いくつかのShapeな図形をラップしている
func makeTrapezoid() -> some Shape {
let top = Triangle(size: 2)
let middle = Square(size: 2)
let bottom = FlippedShape(shape: top)
let trapezoid = JoinedShape(
top: top,
bottom: JoinedShape(top: middle, bottom: bottom)
)
return trapezoid
}
// +++++ モジュールを利用するコード +++++
// trapezoid is type of some Shape
// モジュールの利用者は、この台形が「3つの図形が合体した結果」だとわからない。
let trapezoid = makeTrapezoid()
print(trapezoid.draw())
// *
// **
// **
// **
// **
// *
makeTrapezoid()
関数は返り値の型をsome Shape
として宣言している。
こうすることで、関数に具体的な返り値型を宣言することなく、「Shape
プロトコルに適合した型の値」を返す。
このような方法でmakeTrapezoid()
を書くと、作られた図形の「具体的な型情報」を隠蔽しつつ、モジュールの基本用途である「図形を返す」ことが可能になる。
makeTrapezoid()
関数は台形を作成するために、2つの三角形と正方形を使用した。
この関数は、返り値型の宣言を変更することなく、他のさまざまな方法で台形を描画するように書き換えできる。
この例は、不明瞭な返り値型が「逆ジェネリクスといえる」ことを強調している。
makeTrapezoid()
関数が「Shape
プロトコルに適合していれば、どんな型の値でも返すことができる」のは、ジェネリック関数の呼び出しコードでパラメータを指定するのと似ている(例えば、max(_:_:)
関数を呼び出す際は、Comparable
ならどんな型の値でも指定できる)。
makeTrapezoid()
関数が返す「何かしらのShape
値」を呼び出し側で処理するには、関数を呼び出す際に「ジェネリック関数の実装のようにジェネリクスを利用した方法」で記述する必要がある。
以下の例では、不明瞭な返り値型とジェネリクスを組み合わせるて利用する。
flip(_:)
関数とjoin(_:_:)
関数は、いずれも「Shape
プロトコルに適合した何らかの型」の値を返す。
// ある図形の反転操作を行う、不明瞭な値(some Shape型)を返す関数
// FlippedShape(_:)型を不明瞭型でラップしただけ
func flip<T: Shape>(_ shape: T) -> some Shape {
// FlippedShape<T>値を返すが、呼び出し側にはsome Shapeに見える
return FlippedShape(shape: shape)
}
// 2つの図形の合体操作を行う、不明瞭な値(some Shape型)を返す関数
// JoinShape(_:_:)型を不明瞭型でラップしただけ
func join<T: Shape, U: Shape>(_ top: T, _ bottom: U) -> some Shape {
// JoinedShape<T, U>値を返すが、呼び出し側にはsome Shapeに見える
return JoinedShape(top: top, bottom: bottom)
}
// 定数opaqueJoinedTrianglesはsome Shape型
// 本当は、JoinedShape<Triangle, FlippedShape<Triangle>>型
let opaqueJoinedTriangles = join(smallTriangle, flip(smallTriangle))
print(opaqueJoinedTriangles.draw())
// *
// **
// ***
// ***
// **
// *
定数opaqueJoinedTriangles
は、この章の不明瞭型が解決できることセクションでジェネリクスの例に挙げた定数joinedTriangles
と同じ図形。
ただし、flip(_:)
関数とjoin(_:_:)
関数が「図形操作のジェネリック関数が返す値」を不明瞭型でラップし、それらの型情報を隠蔽している点が異なる。
flip(_:)
とjoin(_:_:)
はジェネリック関数。
どちらの関数もジェネリック型をラップしているので、関数の型パラメータはFlipperdShape
型とJoinedShape
型に必要な型情報を渡すことができる。
なお、「不明瞭型の値を返す関数」に複数のreturn
ステートメントがある場合、それらが返す値は「型をすべて同じにする」必要がある。
ジェネリック関数の場合、その返り値型はジェネリクスの型パラメータを使用できるが、それでも共通の型でなければならない。
次の例では、正方形の特殊条件を含んだ、図形を反転する関数の無効なバージョンを示す。
// 関数の返り値型が一致していないので無効
func invalidFlip<T: Shape>(_ shape: T) -> some Shape {
// 正方形は反転しても無意味なので、そのまま返す
if shape is Square {
return shape // Error: 返り値型(Square型)が他のreturnと一致しない
}
return FlippedShape(shape: shape) // Error: 返り値型(FlippedShape型)が他のreturnと一致しない
}
仮に、この関数を呼び出してSquare
値を渡すと、関数はSquare
型の値をそのまま返すことになる。
それ以外の場合、FlippedShape
型の値を返すことになる。
これは「関数は共通の型の値を返す」という要件に違反しているため、invalidFlip(_:)
関数は不正なコードとみなされる。
invalidFlip(_:)
関数を修正する方法のひとつは、正方形の特殊条件をFlippedShape
型の実装に移動すること。
そうすれば、invalidFlip(_:)
関数は常にFlippedShape
値を返すことができるようになる。
// 作成するインスタンスは常にFlippedShape型
struct FlippedShape<T: Shape>: Shape {
var shape: T
func draw() -> String {
if shape is Square { return shape.draw() } // 移動してきた、正方形の特殊条件
let lines = shape.draw().split(separator: "\n")
return lines.reversed().joined(separator: "\n")
}
}
「常に単一の型を返す」という要件は、不明瞭型の返り値におけるジェネリクスの使用を禁止するものではない。
次に示すのは、返り値の基礎となる型に型パラメータを組み込む関数の例。
// 指定の回数だけ、図形を並べる
// 「Collectionに適合した何らかの型」の値を返す
func `repeat`<T: Shape>(shape: T, count: Int) -> some Collection {
return Array<T>(repeating: shape, count: count) // 返り値にジェネリクスのTを使用できる
}
repeat(shape:count:)
関数が返す値は、基礎となるT
によって異なる。
渡された図形の型T
が何であれ、repeat(shape:count:)
関数は「その図形の配列」を作成して返す。
そういうわけで返り値は常に[T]
型になるので、不明瞭型の値を返す関数は「常に単一の型を返す」という要件を満たしている。
まとめ
返り値のプロトコル型にsome
キーワードをつけて、呼び出し側に対して型情報を隠蔽できる。
// 2つのComparableな引数を受け取って、大きい方の値を返すジェネリック関数
func genericFunction<T: Comparable>(_ x: T, _ y: T) -> T {
if x > y {
return x
} else {
return y
}
}
let someValue = genericFunction(10, 20) // 20; is type of Int
let anotherValue = genericFunction("123", "456") // "456"; is type of String
// 不明瞭型の値を返すバージョン
func opaqueFunction<T :Comparable>(_ x: T, _ y: T) -> some Comparable {
if x > y {
return x
} else {
return y
}
}
// 返り値の型は隠蔽されいる
let opaqueSomeValue = opaqueFunction(10, 20) // 20; is type of some Comparable
let opaqueAnotherValue = opaqueFunction("123", "456") // "456"; is type of some Comparable
利点
- 関数が「どのように実装されたか」を、必要以上に呼び出し側に伝えない
- 呼び出し側は、「不明瞭型の指定するプロトコル」が提供する機能の範囲でプログラミングが可能
- 実装側はプロトコルに適合するのであれば、どんな型の値でも返すことができる
つまり、モジュールやフレームワークの実装が変更されても、それを呼び出す側は「どのような変更があったか」を知らなくて良い。
プロトコル型との比較
例えば、「プロトコルに適合する何種類かの型を要素とする配列」を扱う際に...
- プロトコル型の場合、実行中に起こる変更を処理するために動的なコードが必要。
- 不明瞭型の場合、コンパイラは「実際にどの型が使われるか」を知っている前提でコーディングが可能。
注意点
- 返り値は、プロトコルに適合する「いずれか1種類の具体的な型」であること