https://www.youtube.com/watch?v=bTqDvQWaDYA
今回は Swift API Design Guidelines の「表現方法」から、主にメソッドのベース名に着目して眺めていきます。以前にもベース名を命名規則の観点で見たことがありますけど、今回は『周囲のメソッドとの関係をどう表現していくか』みたいな、より広い視点で観察していってみますね。
———————————————————————————— 熊谷さんのやさしい Swift 勉強会 #19
00:00 開始 00:14 全般的な表現法 01:49 ベース名の扱い 02:26 オーバーロード 03:58 機能の本質が異なる場合のベース名 07:38 ドメインが異なるときのベース名 12:22 オーバーロードについて 17:40 戻り値でのオーバーロード 24:28 戻り値のオーバーロードは曖昧になる? 30:05 標準ライブラリーにみる戻り値のオーバーロード 33:03 unsafeBitCast 37:38 Any 型による戻り値 38:36 numericCast 42:04 Codable 45:24 談笑タイム 47:33 型推論が効かなくなる場面 49:57 オーバーロードの難しさ 51:36 クロージング ————————————————————————————
Transcription & Summarize : 熊谷さんのやさしい Swift 勉強会 #19
今日はなかなか難しそうなところですね。前回あたりから、これまで名前づけという分野を学んできましたが、今回からは表現法に変わりました。そして、いろいろなテーマについてざっくりと3つほど見てきましたが、今回はメソッドのベース名について学びます。
メソッドのベース名については、名前づけのところでも出てきました。例えば、画面に出ている「コンテインズ」や「インデックス・イン・テーブル」などのベース名があります。ベース名というのは、「インデックス・イン・テーブル」の場合、ベース名は「インデックス」で、「イン・テーブル」は含みません。この名前の付け方は既に学習しましたが、今回はそれを踏まえた上で、もう少し応用的なテーマに進む予定です。つまり、ベース名の決め方ではなく、その扱い方に焦点を当てていきます。
Swift言語の場合、overload
という手法が取れます。これは、同じメソッド名でも引数の型などを変えて複数の実装を行うことができる手法です。このoverload
を活用する際、基本的には同じ意味合いで使えるときに限り、ベース名を共有しても良いとされています。overload
とは、ラベル名も含めたすべてのAPIの署名が一致することですが、ここで言っているのはその中でもさらにベース名のことです。つまり、overload
より少し広い範囲の話をしていますね。
同じ意味合いで使われるメソッドについては、ベース名を共有して良いというガイドラインがあります。例えば、図形の中に特定のエレメントが含まれているかを調べるメソッド群は、同じ名前のcontains
を使っても良いです。しかし、その機能の意味が違ってくる場合には、同じベース名を使ってはいけません。
例えば、データベースの型があったとします。その内部インデックスを再構築するメソッドと、データベースの指定した行のインデックスを取得するメソッドがあったとすると、それぞれ違う機能を持つため、同じベース名を使うべきではありません。それぞれ別の名前にしないと、同じ目的のものであると誤認しやすくなります。したがって、例えば「ローアット\(n\)」や「ローアットインテーブル」といった名前にする方が自然です。
名前づけは重要で、その役割をちゃんとイメージしてメソッド名を付けるべきです。意外と名前がぶつかることは少ないですが、もしぶつかったとしたら、そのメソッドの役割が多すぎるということが考えられるでしょう。
まだベース名についての話が続きますが、同じベース名であってもドメインや影響範囲が異なるときは同じ名前を使っても差し支えないというガイドラインもあります。例えば、ある図形の中に何かが含まれているかを調べるメソッドと、その他のドメインで似たような機能を持つメソッドがあるというケースです。 とりあえず、「コンテインズ」はコレクションに対して要素が含まれるかどうかを判定する関数です。非常に似通った名前ですが、Swiftでは名前空間が型名などで分けられているため、違う空間に属していれば同じ名前でも別の意味として扱われ、誤解は生じにくいというルールになっています。
もっと明確な例を出したいのですが、「コンテインズ」は少し似すぎているかもしれません。日本語の自然言語でも、同じ単語が異なる意味を持つことがあります。たとえば、「ビルド」という言葉は、コンパイラー関連で使えば「コンパイルする」という意味になりますし、「ハウス」関連で使えば「建築する」という意味になります。このように同じ言葉でも分野によって異なる意味を持つことがあります。「インターフェース」も場合によって意味が変わることがあります。
結論として、シェイプでいう「コンテインズ」とコレクションでいう「コンテインズ」は別物です。シェイプの「コンテインズ」を理解したとしても同じ動きをするとは限りません。基本的には、同じ名前でも型や文脈が異なるものは別々のものとして扱われます。
次に、戻り値によるオーバーロードについて説明します。オーバーロードとは、同じ関数名で異なる引数を持つメソッドを定義することです。昔のC言語ではオーバーロードが存在せず、例えば最大値を取る関数を作る際は、引数の型ごとに別の関数名を考える必要がありました。C++やSwiftでは、オーバーロードが可能です。
具体例として、二つの値から最大値を取るメソッドを考えます。Swiftでは、例えば max
関数を Int
型の引数でオーバーロードすることが可能で、さらに Double
型の引数でも同じ名前の max
関数を使うことができます。
func max(a: Int, b: Int) -> Int {
return (a > b) ? a : b
}
func max(a: Double, b: Double) -> Double {
return (a > b) ? a : b
}
このように、同じ名前の関数でも引数の型が異なれば異なる関数として扱われます。一方、オーバーロードができない言語では関数名をずらして、例えば maxInt
や maxDouble
のようにすることで対応します。
さらに、Swiftでは戻り値によるオーバーロードも可能です。たとえば、戻り値の型が異なる関数を同じ名前で定義することもできるため、非常に柔軟な関数定義が可能になります。他の言語では戻り値によるオーバーロードができないこともありますが、Swiftでは可能です。これもSwiftの強力な機能の一つと言えます。 ただ、型推論のある文脈では曖昧になるので避けた方がいいというガイドラインが用意されています。以下の例では、同じバリューを持つ場合に、それを明示的に書いた方が分かりやすいですね。
よくありがちな戻り値でのオーバーロードの例として、独自にバリアント型を作ったときの話です。バリアント型とは、様々な型を同時に扱えるように設計される型のことです。型が緩い言語を表現する方法とも言えますが、Swiftのような厳格な型システムを持った言語でも、型に緩い制約を持つ型を設計することが重要だと思います。
例えば、バリアント型のプロパティをAny
型として持っておき、次のようにすることができます。
var value: Any
このプロパティを取得するときには、bar value
のように戻り値のオーバーロードを使わない方法もあります。例えば、以下のようにします。
func value(asInt: ()) -> Int? {
return value as? Int
}
func value(asString: ()) -> String? {
return value as? String
}
こうすることで、バリアント型からInt
型の値を取得したい場合にはvalue(asInt:)
とし、文字列として取得したい場合にはvalue(asString:)
とすることができます。この方法はオーバーロードを使わない方法です。
これをオーバーロードを使って書く方法もあります。ただし、Swiftではプロパティのオーバーロードはできないので、関数として定義する必要があります。例えば、以下のようにします。
func value() -> Int? {
return value as? Int
}
func value() -> String? {
return value as? String
}
この方法では、同じ関数名value
を使い、戻り値の型だけが異なるように定義します。ただし、ガイドラインではこの方法が推奨されていません。なぜなら、型推論の面で曖昧になるからです。個人的にはこちらの方がかっこいいと感じていた時期もありましたが、実際には避けた方が良いかもしれません。
実際に両方の方法を実装して比較することも考えましたが、型推論で曖昧になるケースが多いです。ですので、やはりガイドラインに従って、戻り値のオーバーロードを避ける方が良いでしょう。
例えば、以下のようにするのが良いでしょう。
func intValue() -> Int? {
return value as? Int
}
func stringValue() -> String? {
return value as? String
}
このように命名することで、同じvalue
というメソッド名を使わずに済み、どの型が返されるのかが明確になります。読みやすさや型推論の曖昧さを避けるためには、こちらの方法が推奨されます。 例えば、配列があってその中に値が入っているとします。ここでは、バリアント型が入っている場合について考えましょう。ただし、バリアント型を書かないと全然バリアント型を活かせていない例になってしまいますが、今回は気にしないでください。
さて、バリアント型の配列をバリアントではない配列に展開しようと思ったときに、values.map
のように書いたとします。しかし、これは一体どのvalue
なのかと疑問になるかもしれません。たとえば、インテジャーバリューであれば明確に決まります。
このようにメソッドチェーン的に書かれている場合、例えば以下のようにコードを書いていくことができます。
var values = (0..<100).map { _ in
return Int.random(in: 0..<100)
}
この配列からプレフィクス10個だけを取って、さらにマップでバリアント型に変換する例を示すと、以下のようになります。
let prefixValues = values.prefix(10)
let variantValues = prefixValues.map { $0 as Variant }
このように書いていくと、比較的サクサクとコードを書き進めることができます。ただし、どれがインテジャーバリューなのか混乱するかもしれません。
ガイドラインに従うと、同じ名前のオーバーロードを使うのではなく、全く異なる方法を使うルールになっています。標準ライブラリはこのルールに則ることが推奨されています。
ここで、自分の好きなものの一つにunsafeBitCast
というものがあります。これは戻り値にオーバーロードをしない形で使われます。例えば、関数でジェネリクスを使う場合、以下のように書くことができます。
func genericFunction<T>(_ value: T) -> T {
return value
}
このジェネリクスの関数は戻り値のオーバーロードを行っていないため、ガイドライン的には問題ないと考えます。実際にこのような書き方をすることは一般的です。
unsafeBitCast
の話に戻りますと、例えば以下のように型の変換を行う場合があります。
let intValue: Int = unsafeBitCast(someValue, to: Int.self)
この場合、型U
をあらかじめパラメーターで受け取ることによって、戻り値の型を推論できるようにする手法です。このようにすると、戻り値は必然的に指定された型になります。
例えば以下のような定義があります。
func unsafeBitCast<T, U>(_ value: T, to type: U.Type) -> U
このような方法で型の変換を安全に行うことができます。 受ける先で型推論が効いている場合、U
は必然的に決まってくるため、パラメーターで取らなくても成り立ちます。しかし、これを避けて上記の方法を標準ライブラリが採用しているのは、もしかすると戻り値だけで型推論に頼るのを嫌っているのかもしれません。こうして情報を持たせる意味合いについて説明しているのが、このスライドの意図だと考えられます。
さきほど勉強会の前に話題になった点として、プログラマーレビューをする側にとってもありがたいという意見がありました。これは、開発者目線でも妥協しない言語だというスタンスから、コードが何型に変換しようとしているのかを分かりやすくするためです。たとえば、読み取りやすさの面で設計がなされているわけです。
この部分については、自分の中でも評価が難しいところですが、少なくとも戻り値の型だけで型推論に頼るのは控えるべきだというガイドラインがあると意識しながらコードを書くと、紹介したunsafeBitCast
のように読みやすい表現ができることもあります。
コメントでいただいたのですが、UserDefaults
のvalue(forKey:)
はAny
型を戻します。このAny
型は型を縛らず、ジェネリクスとほとんど同じ意味を持ちます。ただし、ジェネリクスが持つ型推論の価値は大きいです。
さて、unsafeBitCast
について紹介しましたが、標準ライブラリが47行目の方法だけを取っているわけではありません。たとえばnumericCast
という関数があります。たとえば、ある値がInt
型で、ある値がDouble
型のときに、numericCast
を使うとUInt32
のように変換ができます。
動作させると、コンパイルが通り変換が行われます。これは完全に戻り値の型でオーバーロードしている例です。標準ライブラリでは、この方法も取り入れられています。numericCast
は純粋にジェネリクスを使っており、戻り値による型推論に完全に頼っています。つまり、unsafeBitCast
とは対照的な手法を標準ライブラリに取り入れています。
これは、ガイドラインと矛盾しているのか、それとも別の話なのかはまだ明確ではありません。各手法を取る場合も、それぞれに適した使い方があると思います。たとえば、Codable
もこの手法を取っています。Codable
を使う場合、JSONEncoder
などで型をより明確にすることが重要です。
標準ライブラリでは、戻り値の型と型推論を加味した手法がいくつか存在します。オーバーロード、ジェネリクス、パラメーターで型情報を取る方法などの三種類があります。 とりあえず手法があるので、仮にこの戻り値の型でオーバーロードする機会になったときには、今挙げた3つのポイントを頭の中にパッとイメージできるようになると良いですね。そうすると、「ここではどれが適切かな」という判断材料につながると思います。
ここまででメソッドのベース名に関するガイドラインは終わりです。次から引数の表現方法について見ていくことになります。ここもなかなか意外と面白いです。今回この資料を用意していて、自分の中で気づいた抜けがあったりして、面白かったなと思います。これはまた次回ゆっくり見ていこうかなと思います。
とりあえず、そんな感じです。時間的にも良い感じになったので、ここでスローダウンして、ここまでで何かありますかね。いろいろコメントを寄せてもらっていますが、それ以外にも、コメントだと「UIKitのUIColorとSwiftUIのColor、自動補完が効かない」という話がありました。そうなんだ、さっきのメソッドチェーンと似たような感じですね。戻り値の型が決まっていないと値を見ているままで補完が効かなくなる。
確かに、途中の段階で全然効かなくなるときがあります。それは、さっきの例だとオーバーロードしたメソッドを使って、続きを拡張するみたいなときに型が決まっていないから出てこなくなるってことです。ただ、まあその時も「これはintだよ」とか書いちゃえばもう出てきますからね。
型推論って戻り値だけに限らず、関数名や関数呼び出しでも、実際にどのメソッドを読んだらいいかが分からなくなって結局型キャストすることがありますもんね。たとえば、$0 as Int
とか。でも少なくはなってくるのかな。
でも確かに、言われてみると戻り値で曖昧になることが多い気がします。メソッドのパラメータのほうは、そうそう曖昧になることは少ないんですけど、イニシャライザで曖昧になることが特にマップを使っているとあったりします。例えば、String型に変換したいときにどのイニシャライザを使うのか決めなければなりません。3つの引数を取るタプルとかだと名前を明記してあげたりする必要がありますね。
実際、コードを書き間違えることもよくあるんですよね。全然違うメソッドを呼び出してしまったり。その場合は、明らかな間違いとしてすぐに分かるんですけど、戻り値の型推論がしっかり決まっていれば、例えばintを期待していたけどなぜかキャラ型の補完が出てきたときに気づけたりします。それで結構違ってくるのは確かにあります。
オーバーロードはときどき難しい初学者にとっては確かに難しく感じるかもしれませんね。でも、引き数でそれなりに味付けができると、パラメータを渡していく中で型を明確にすることが求められます。たとえば、「$0を何型にするのか」それを決める必要がありますよね。ここで取れるのはint型にして扱うとか。
オーバーロードしている場合でも、していない場合でも、パラメータで味付けしてあげるのは直感的で分かりやすいコードになる気がします。UINTが返ってくることが分かれば、UINTに対して何をしようか考えながらコードを書いていけます。
程よく良い時間になりましたので、今日のガイドラインを読むのはここまでにして、また次回、月曜日に続きを読んでいきましょう。
では、皆様1時間お疲れ様でした。