https://youtu.be/KR_XbjKYOGk
今回は A Swift Tour
における残り3枚のスライド、Generics
の型パラメーター制約の書き方に関するあたりを眺めていきます。内容については軽量なので、本の中で出てきたコードについてジェネリクス視点以外でも眺めてみたいと思うのと、そうしてもし時間が余ったとしても、ジェネリクスにはいろんなスタイルがあるので、それらについて軽く見渡していくみたいな回になりそうな予感です。どうぞよろしくお願いしますね。
——————————————————————————— 熊谷さんのやさしい Swift 勉強会 #76
00:00 開始 00:37 型パラメーターと制約 04:33 総称関数に制約をつけていってみる 07:26 総称関数を実装してみる 08:41 要素を内包する値を受け取る 10:23 Collection を想定する 13:12 Sequence を想定する 14:16 ドキュメントコメントのガイドライン 18:12 実装を記載していく 18:51 Equatable 19:28 関連型 20:21 関連型での制約の付け方 22:52 比較可能を想定する 24:09 制約を書く順番 25:37 練習問題 27:01 配列を返すようにする 29:17 リファクタリング 30:02 制約の記載場所と順番 34:11 Swift らしい実装を考える 35:06 手続き型と関数型 40:26 条件演算子に対する印象 41:23 reduce を用いた方法 42:00 いったんクローズ 42:32 クロージング後のロスタイム 44:06 計算量 46:43 Hashable と Set を用いる方法 48:56 より原始的なプロトコルを選ぶ 51:10 条件演算子を使わない方法は? 54:43 クロージング ———————————————————————————
Transcription & Summarize : 熊谷さんのやさしい Swift 勉強会 #76
では、型パラメーターに制約をつける話を始めましょう。前回は、型パラメーターを持てるという話をして、列挙型や構造体でタプルを作ったりしましたね。今回は、この型パラメーターに制約をつけられるという話を進めていきます。
この制約をつけるというのも、前回少しだけカスタムストリングコンバーティブルを使って制約をつけるというのを見ましたが、今日はそれをもう少し丁寧に見ていこうと思います。
重要なポイントは、画面に映っている where
キーワードの部分です。具体的にはプログラム的には1行目になりますが、where T.Element: Equatable
という表現です。ここは型がどういったものかを説明するためのものになっています。
具体例として、anyCommonElements
という関数を考えます。この関数は型 T
と U
を取りますが、T
は何でも良いけれども T
はシーケンスであり、U
もまたシーケンスで、両方ともシーケンスだけれど異なる型である、という制約を加えています。そして、さらに T.Element
と U.Element
は同じ型であるという制約も加えています。これによって、T
と U
は別々の型でありながら、要素は比較可能な同じ型であることが保証されるわけです。
では、このコードを実際にプレイグラウンドで見ていきましょう。anyCommonElements
関数は次のように書けます。
func anyCommonElements<T: Sequence, U: Sequence>(_ lhs: T, _ rhs: U) -> Bool where T.Element: Equatable, T.Element == U.Element {
for lhsItem in lhs {
if rhs.contains(lhsItem) {
return true
}
}
return false
}
この関数は左辺 (lhs
) と右辺 (rhs
) の T
と U
という異なる型を取り、それぞれの要素が共通しているかを確認し、共通の要素があれば true
を返します。具体的な引数の例として、例えば a
が [1, 2, 3]
という配列で、b
が [4, 5, 1]
という配列であれば、この関数は true
を返します。
まず、何も制約をつけない場合について考えます。次のようなコードになります。
func anyCommonElements<T>(_ lhs: T, _ rhs: T) -> Bool {
// ...
}
ここでは型 T
に制約がないので、全く異なる型の引数を取ることができます。しかし、これではプログラムを書く上で曖昧さが生じます。例えば、以下のような異なる型の引数 a
と b
を渡すことが可能です。
let a = [1, 2, 3]
let b = Set([3, 4, 5])
print(anyCommonElements(a, b)) // 実際にはエラーになります
このように、異なるコレクション型に対して比較可能な要素が含まれていることを保証するために、制約を追加していきましょう。まず Sequence
に対する制約を加えます。
func anyCommonElements<T: Sequence, U: Sequence>(_ lhs: T, _ rhs: U) -> Bool {
// ...
}
次に、要素が比較可能であり、さらに両方のコレクションの要素が同じ型であることを保証します。
func anyCommonElements<T: Sequence, U: Sequence>(_ lhs: T, _ rhs: U) -> Bool where T.Element: Equatable, T.Element == U.Element {
// 関数の実装
}
このようにすることで、lhs
と rhs
が異なる型のコレクションでも、その要素が同じ型であることが保証されます。例えば、以下のようなコードで正常に動作するようになります。
let a = [1, 2, 3]
let b = Set([3, 4, 5])
print(anyCommonElements(a, b)) // true
このように型パラメーターに制約を加えることで、より安全で汎用的なコードを書くことが可能となります。次回はさらに深掘りして具体的な使用例や他の制約の付け方を見ていきます。これで今回の解説は終了です。 という意味を持つプロトコルになっています。この辺りを知るには定義を辿って、その定義を見て、コメントや持っている機能で推察していく感じになります。普通はコメントを読んでいくのがSwiftでは適切かなと思います。ここを見るとコメントが非常に長いですが、最初のほうに「コレクションとはシーケンスですね。それでさらにインデックスアクセス可能です」といった感じの話が書いてあります。
APIデザインガイドラインのときに出てきた内容ですが、とりあえず最初のコメント行を読めば概要を把握できるよ、というガイドラインになっているので、大体そこだけ見れば分かるかと思います。これがコレクションですが、このコレクションがシーケンスというプロトコルにも準拠しています。このシーケンスプロトコルの定義を確認すると、シーケンスは特に継承関係はありませんが、そのコメントを見ると「シーケンシャルイテレタブルアクセスを提供する」と書いてあります。つまり、シーケンス自体がエレメントを持つプロトコルだということです。
今回作っている関数では、渡された値が持つ要素という要件を満たして、さらにイテレタブルであれば十分です。そのため、コレクションのようなより応用的なプロトコルを使うよりは、要件を満たす限りで最も基本的なプロトコルを使ったほうが、そのジェネリクスの適用範囲が広がっていくので、なるべく原始的なものを使ったほうが良いです。なので、ここはシーケンスで縛っておきましょう。
こうしても、ArrayもSetもシーケンスに準拠しているため、これで動かしてエニコモンエレメンツを渡すことができます。これで制約としてはOKです。ただ、エラーが出ているようですね。これは残っているだけだと思うので、話を進めていきます。
ここまでできると、PとUがシーケンスとして得られているので、実際にプログラムが書ける状況になりますが、実際に書くときにはいくつか問題に直面します。書いてみましょう。
まず、左側の要素を全部回して、次に右側の順に見ていって、一致するものがあったらtrue
を返すというコードを書いていきます。
for leftHandSideItem in leftHandSide {
for rightHandSideItem in rightHandSide {
if leftHandSideItem == rightHandSideItem {
return true
}
}
}
return false
このようなコードを書きたいわけですが、現在の制約だと実現できないところがあります。一致判定ができないためです。これを実現するためには、さらに制約を追加してあげます。
具体的には、where
句を使って、Tの持つエレメント、具体的にはシーケンスの定義にあるエレメントを指定します。シーケンスの定義の中にエレメントというものがあって、これはジェネリクス的な表現になっています。アソシエティブタイプとして型は何でもいいですが、エレメントというものをシーケンスは付属しています。このエレメントは、イテレーターのエレメントと一致している必要があります。イテレーターも同様に型は何でもいいですが、イテレータープロトコルに準拠しているものという規定があります。
関数を作る上では、これ以上の深追いは必要ありませんが、イテレータープロトコルも内部で順次値を取れるプロトコルであり、その値もエレメントとして所属していると覚えておく必要があります。 なので、ここでは何を言っているかというと、イテレーターが順次返していくエレメントと自分自身の関連型として規定しているエレメントは同じだという規定になっています。この書き方が昔はできなかったのかな? はっきりと覚えていませんが、昔は Swift 2とか3の頃かな、sequence.generator.element
みたいなふうに書いていました。昔はイテレーターをジェネレーターと呼んでいたので、今風に言うと sequence.iterator.element
という感じで書いていた頃があるんですが、今は映っているような定義によって sequence.iterator.element
と sequence.element
は同じものとして定義できるので、実質 sequence.element
と書けば問題なくなったという仕様変更があります。昔Swiftを触っていて最近触っていなかった人は、その辺りを改めてみるといい感じに書けます。
要は、ここに t.iterator
と .element
を書いていますが、わざわざこう書かなくても t.element
と書けばいいよ、という話です。
話の続きですけれど、比較をしたいというわけなので、Swiftでは一般的に、値の比較をするときには同じ型でなければいけません。なので t
と u
のそれぞれのエレメントは同じ型ですよという規定をしなければいけないんですが、これだけではまだ足りません。上を書く位置を間違えましたね。戻り値の型の後ですね。ここですね。
とにかく実行してみると、これだけではまだダメなんです。なぜかというと、==
を使用するためには比較可能でなければいけません。なので t
と u
が一致するだけではなく、さらに t
のエレメントが Equatable
でなければいけません。ここまで書けばコンパイルが通るようになって一応コードが完成しますね。
ここで補足しておきたいのは、u
のエレメントも Equatable
にしないとダメなのではないかと混乱するかもしれません。でもこれは必要ありません。警告で教えてくれますね。すでに u
のエレメントは Equatable
としてマークされているよ、と教えてもらえます。t
のエレメントが Equatable
ですよと規定してあって、さらにその上で u
のエレメントも t
のエレメントと同様と規定してあるので、実質、自動的に u
のエレメントも Equatable
に決まります。この表示中のコードだけでしっかり決まっているので、コードが成り立ちます。
こういった感じですね。ジェネリクスの関数、なんて言うんだっけ…総称関数ですね。今回作りたかった総称関数はこれで一応完成です。
そうですね、練習問題があるのでそれに取り組んでみましょう。まず練習問題がどういったものかをご紹介しましょう。両方のシーケンスが共通して持つ要素を配列で返すように書き換えてみましょう、という問題です。今は共通して持つ要素があったら true
を返すようになっているコードですが、これを共通して持つ要素、つまり共通部分を配列で返すようにしましょうという問題になります。
では、実際にそれをコードに書いていきましょう。まず共通する要素を抜き出すには、共通する要素を持つ配列を返す。これで理解してもらえるでしょうか。要素を内包するLHSとRHSで共通する要素を持つ配列を返す。ちょっと微妙な表現ですが、今回はこれで進めます。
そして、戻り値が共通する要素を持つ配列ということになります。 なので、まず配列ですよね。要素を返すわけだから、この中でエレメントを持つ配列を返してあげればよさそうですね。Pのエレメントと代表して書きましたけど、その後の制約のとおり、PのエレメントもUのエレメントも同じなので、どっちでも大丈夫っていう感じになりました。
手続き言語的な書き方をしていくのが今回は楽かな。バーとしてリザルトという感じで変数を用意して、これが空っぽでPのエレメント型。ちょっと書き方に慣れてないとまどろっこしいかな。これね、素直に書くとPのエレメント型としてとりあえず空っぽです、というふうに用意します。そして、レフトハンドサイドのアイテムとライトハンドサイドのアイテムが一致したときには、このリザルトに対してアペンドします。どっちでもいいのでアイテムをアペンドしてあげて、最後にリザルトを返します。このような感じで。これで動くかな、ちゃんと。
これで動かすと共通する部分が返ってくるとすると、1と3と5が得られる感じになるのかな。練習問題としては解けたという感じになるかなと思います。コードが若干複雑で冗長な感じもするので、もう少し書き換えていったら面白そうかなと思うところがあります。ちょっとそのあたりも見ていこうかなと思います。まずは動いてほしい。
動いた動いた。135ですね。ちょっと面白くないな。ここに0が入っていたりしても結果は一緒ですけどね。135それですね。では、こんな感じで動いたけど、どういう形にしていこうかな。まずただの表現の問題ですけれど、このシーケンスっていう制約ね。これはコードが良くなっていくとかどうとかの話ではなくて、書き方の一例ですよ。それの紹介なんですけど以前この勉強会でも出てきましたけど、このシーケンスって書くのをこの型パラメーターのところに書くのと、この外側でwhere
の中でシーケンスって書くのと全く一緒なので、こういう書き方でもOKです。
例えばT
はシーケンスで、そのT
のエレメントは何で、みたいに条件を後ろにまとめていく書き方もできる。ただ、今のこの感じだとT
は後ろに書いて、U
は型パラメーターのところに書いて、というちょっとチグハグなのはあまり好ましくない気がするので、こうやってU
もシーケンスというのをここに書いてあげると、バランスが良くなってくるのかな。
で、ここ例えばU
のシーケンスですよって記載をここに書きましたけど、後ろに書いても大丈夫でしたよね。あまり自信がないけど、大丈夫だよね。動いたね。こういうふうにね、出る順番はとりあえずそんなに問題はないみたい。いきなりここでU
のエレメントはとか語り出してますけど、後でU
はシーケンスですよ、みたいな感じで追加して説明してもOKです。同様に、これは先でも後でも順番に書けば見事に解決できます。
ですね、書いていけばいいんだけど、流れはやっぱりね、順番通りに。T
がシーケンスで、U
がシーケンスで、それでT
とU
のエレメントは同じであって、T
のエレメントはイコータブルですよっていう、そういう流れにしたほうが分かりやすいですね。この点に関してはどうなんだろうね。T
のエレメントがイコータブルですよっていうお話は、好みの問題だと思いますけど、先に書くべきか後に書くべきか。まあ、個人的には先の方が分かりやすいかな。T
はシーケンス、U
はシーケンス、T
のエレメントは比較可能、そしてT
のエレメントとU
のエレメントは全く同じものですよ、みたいな感じ。なんかその方が分かりやすい気がしますね。
まあそのあたりは、それぞれの流儀とかコーディングルールに依存するので、ここまで書かなくていい気もしますけれども、そういったコーディングルールで書いていけばいいでしょう。ちなみに、この型パラメーターの中でT
のエレメントに制約をかけるのはできません。ここでT
のエレメントは、と書くわけにもいかないので、細かい条件をかけるときには必ず後ろに回していく必要があります。
では、ここからもうちょっと実際のコードの中身を書き換えていきたいと思います。その前にコメントを読んでいってみましょう。そう、スイフトらしい実装は何だろう、というテーマですね。なるほど、そうですね。コンパクトマップとかを使ってるっていう話かな。そのあたりを今書いていきますけど、そのあたりの他に、イコータブルを一番先頭に書いたらどうでしょうか、っていうのはやったやつかな。そんな気がしますね。一応もう一回やってみますか。これを先に書いても問題なくコンパイルが通るんですよ。こうね。うん、こんな感じでいろいろと表現力は豊かになっていますね。では、実際のコードの中身を書いていきましょう。
このスイフトらしいコードを書いていこう、という話になったときに とりあえず、頭に上がってくるのがvar
っていうのがちょっとよろしくないんじゃないか、みたいな話になると思うんですよね。個人的には、書き方として昔はvar
じゃなくてlet
を使うというのにこだわっていた時期が自分にはあります。ただ、最近はもうvar
を使っても別に悪くないなと思っています。ちゃんと適切なスコープで閉じていれば、そんなに問題ないからです。
手続き言語的な書き方をここに局所的に混ぜ込んでいくっていうのは、別に悪くないかなと捉えています。Swiftらしいコードを書くときに全部let
で表現していくのが良いと考えたとき、実行パフォーマンスも良くなっていくと思います。結局、裏で配列を作ることになるので、そのあたりが効いてくるのです。
Swiftらしさがどこにあるかというと、それは関数型っぽくなるのか、手続き型っぽくなるのかという話に繋がっていくと思います。Swiftでコードをサクサクと書くときに、どちらかというと関数型の方が相性が良いんじゃないかと考えます。プロトコル思考では宣言的なコードを書くので、宣言的なコードと関数型的なコードがとても似通っていると言えます。もしかすると同じかもしれません。
Swiftらしさがプロトコル思考だとすると、それに寄せていくと手続型よりも関数型の方が似合ってくる感じがします。こうやって書いていくと必然的にvar
は使わなくなり、let
が主流になってくると思います。let
に縛っていく、var
を使わないように心がけてSwiftでコードを書くことで、関数型っぽくなってさらに宣言的なプロトコル思考的なコードに近づいていきます。そういう考え方をすると、よりSwiftらしくなっていくでしょう。
コメントで提示されたコードを採用すると、とても分かりやすそうなので、それに置き換えていこうと思います。変数に代入する形を取るのではなく、var
だったのをlet
にする感じですね。例えば:
let leftHandSide = ...
let rightHandSide = ...
そして、「leftHandSide.contains($0)
」のように書くと、leftHandSide
のアイテムがrightHandSide
に含まれていれば、それを返し、それ以外の場合はnilを返します。それによって最後にcompactMap
で除外する、という書き方です。結果的にループ文がいらなくなり、これでコンパイルが通る形になります。実際に動いて問題なければ、それでバンジーOKです。 とりあえずこうやって書けるから、ざっくりとここの変数let
さえいらなくなってリターンが書けるようになります。これで短くなるし、さらに1ステートメントなのでリターンもいらなくなります。こういうふうな書き方ですね。こういう感じが一つシンプルで、慣れている人にとっては読みやすいコードになるかと思います。
「三項演算子が微妙」というコメントをいただいているのですが、どうなんでしょうね。三項演算子自体がそんなに悪いことでもない気がします。個人的には「普通はないんだけど、なんかダサいなあ」とコンパクトマップで除外しているので何かいい方法がないか考えましたが、「短く書きたい」というのも優先しているんですね。
なるほど、いろんな方面で欲張りすぎているんですよね。どれかしらを妥協すればそれでいいんじゃないかとは思いますが、理想を高く掲げて考えていくのは大事です。もう一つコメントで、面白いコードのアイデアが出てきました。reviews
を使う話ですね、reviews into
を使って空っぽの配列に追加していくコードです。
処理コストはこっちのほうが多分低いですね。おそらく、配列に詰めてるだけなので。contains
の処理コストは結構大事で、contains
で1だったらと三項演算子か、まぁ、遊ぶだけですけどね。reviews
を使うときには$0
のappend
として$1
を使わなければならないという形ですね。
面白いですね、この表現。なるほど、前回ちょうど話に出てきましたが、三項演算子でボイドを返すのが気持ち悪いという話がありました。その気持ち悪い版を採用して、上手に今回パフォーマンスよく共通部分を切り出すという書き方ができたものですね。面白いです。
このボイドを三項演算子で使うという発想がなかった場合、reviews
のreviews into
を使って空の配列にして、右辺が$1
の場合追加するみたいなコードになるんですよね。if
文を使って、append
。これで$1
と、こういうふうなコードになります。どっちが分かりやすいかは人それぞれですかね。
結局二つの配列がある場合の計算量の話ですが、O(N^2)
になるという意味か。なるほど。計算量はfor
ループで回していても、このcontains
とcompactMap
やreviews
をやっていても、計算量は一緒だと思います。裏事情的な速度では、compactMap
を使うと配列が大きくできるかどうかで、計算してそれを複製するというのが増えるんじゃないかな。
compactMap
の設計次第というわけですね。どっちももしかするとreviews
と似たような速度かもしれないです。先程の話でreviews into
のほうがパフォーマンスが期待できそうという話をしましたが、compactMap
と一緒の可能性もあります。
今回の話では、計算量や処理時間が増えてくる可能性があるのはreviews into
を使わない場合なんですね。これを使わないと、left-hand sideのcontains $1
というような書き方をしないといけない。しかし、Int
じゃない方のreviews
はappend
を使えないので、$0はlet
として固定される必要があります。$0
に対してcontains
だったら $0 += $1
といったようなコードになります。これを用いると、配列を順次作っていかないといけないので、パフォーマンスが悪いです。
ですので、reviews into
を使っていくということになるんですね。もう一つ、ハッシュ可能に制約をかけて、セットに直してインターセクションを取る方法がコメントで寄せられています。それが最適だという意見がありますが、まさにその通りですね。 そこに行く前に、もう一つ。このコメントで思い浮かんだセットを使う方法っていうのもありますね。例えば、あ、var
でもvar
使わないといけないのか。セットの初期化方法にはどんなものがあるんだ?セットのイニシャライザーは、まあ、一個しか取れないか。そうするとvar
を使わないといけないですね。var
でセットを作って、そこに積み重ねることになっちゃうから、まああまり良くないか。セットはシーケンス一個しか取れないものね。e
とu
を合成しないといけないから。うん、やっぱりEquatable
じゃなくて、Hashable
っていう手もあるというのをちょっと試してみますかね。
Hashable
はEquatable
にも準拠しているからっていうのがまず第一にあって、Hashable
にすることでセットが取れるので、セットとして扱えるようになります。そうするとセットにしてインターセクションがセットにするためにはインスタンス化しないといけないですね。で、同様にライトハンドセットもインターセクションは実は初めて使ったんですけど、そうか、配列に変えないといけないんだ。そうすると練習問題は配列で返せって言ってましたけど、セットにするのが妥当になってきますかね。こういう書き方をすると、確かに自然にサクッといけますけどね。セットにインスタンスを振り返る、要は変換しないといけないコストは気になるところですけれどね。そういったところかな。ただ綺麗といえば綺麗ですね。1行で書けるという意味になりそうな気がするんですけれど、このコードの懸念するところとしては、Hashable
はEquatable
よりも発展したプロトコルになっているので、Equatable
に準拠しただけの要素が扱えなくなっちゃうんですよね。要はHashable
とEquatable
はこういう関係にあるので、Equatable
だけに準拠しているものが扱えなくなっちゃう。
さっきのコレクションとシーケンスのお話でも出てきましたけど、あくまでも想定する状況によるんですけど、今回共通する要素を配列として取りたいという課題の中では、Hashable
に縛っちゃうと、取り得る要素が狭まれてきちゃうっていう心配があるので、そういうところを考えてみると、綺麗さを追求するあまりセット、要はHashable
に要素を制約してしまうっていうのが若干やりすぎかなという感じもしなくもない。そうすると、この辺の三項演算子を取ったreduce
あたりが、Swiftらしさも生かした上で短めなコードになってくるかなっていう気がしますね。ただ若干見にくい気がするな。やはり三項演算子かな。三項演算子を使わなくて済む方法とかがないのかな。組まれていったらappend
、if
文ですよね。三項演算子を使わないとなると、あとはcompactMap
を想定したさっきの例をもうちょっと工夫していくと何かいいことあるかなというのと、contains
だもんな。ここを自然につなぐ方法はないもんね。そうね、三項演算子でいい気がしますね。
この辺り、もっとシンプルな発想で書き換えられるっていう方いますかね。もうちょっと何か補助的な型は作りすぎか。そうだな、append
したいだけなんですよね、条件に一致したら。そうね、でもそうやって考えていくと、reduce
かどうかの違いなだけなんですけど、こっちもなかなか悪くないですね。やっぱり、reduce
を使ったときにはここnil
じゃなくてVoid
になるっていうのが若干、自分が慣れてないだけだとは思うんですけど、一体何をしているんだって思考を止めてしまうところがありますよね。compactMap
なら全然問題なくnil
って分かるじゃない、無視されるものってわかるじゃないですか。なので、こういったところがちょっと気になったかな。あとは、この三項演算子を対応するか否かっていうところですよね。ここは難しいな。これ以上詰めていってもif
文かswitch
文になるかなっていうところ。あとは、あとは何もないか。やっぱり全部回していかないといけないですから、論理積とかってそういう処理は取れないか、取っても効率悪くなるだけかな。こんなところですかね。もう一歩詰め込めそうな気がなんとなくするんですけど、まあいいか。ここはゆっくり自分で考えてみますね。他の方々も面白そうだなと思ったら、いろんなコード書いてみてもらって、面白いの見つけたら、またこの勉強会なりOJTチャンネルなりで教えてもらえたらいろいろ面白いことになりそうですよね。コメントもいただいてますけど、もうちょっと快適にできる方法がありそうな気もするんですよね。まあここまでかもしれないですけどね。まあちょっといろいろ興味ある方々でもうちょっと広げてみましょう。
では、勉強会としてはこれぐらいにしておきましょう。長い時間ありがとうございました。お疲れ様でした。