https://youtu.be/hS9lCCliUr8
前回はその中で話題にのぼった StringProtocol
をきっかけに Collection
について意識を傾けていく機会に恵まれましたけれど、自分が少し捉え違えて話を進めていた感があったのと、これまであまり意識して見てこなかったことも登場してきていたので、改めて今回はその Collection
について、The Swift Programming Language からではなく標準ライブラリーから窺えることを中心に、前回の補足も兼ねつつ奔放に眺めてみようと思いますので、どうぞよろしくお願いしますね。
———————————————————————————————————————— 熊谷さんのやさしい Swift 勉強会 #79
00:00 開始 00:24 今回の展望 01:49 StringProtocol の制約 02:10 StringProtocol に準拠できる型 03:48 String と NSString は独立した存在 04:06 NSString は StringProtocol に準拠していない 04:56 Substring によるパフォーマンス向上 07:26 ジェネリクスで StringProtocol を扱う 12:37 Objective-C ブリッジでインスタンスが振り替えられる 15:31 コレクションの性格を見ていく 16:06 コレクションとは 16:36 コレクションに関係する型とプロトコル 22:58 プロトコルが性格ごとに事細かに分けられている理由 24:06 シーケンスとコレクションの類似性 28:12 Collection プロトコルの特徴 31:47 非破壊で巡回できるか否か 35:17 個々の要素にアクセス可能 35:39 end と last と past the end 39:20 余談 : Swift の String を壊す事例 41:08 インデックスのセマンティクスを継承することが求められる 43:38 プロトコルはコンパイラーでは検出できない制約も含む 46:44 次回の展望 ————————————————————————————————————————
Transcription & Summarize : 熊谷さんのやさしい Swift 勉強会 #79
はい、じゃあ始めていきますね。今日はちょっと脱線して、前回お話ししたストリングプロトコルに関連するコレクションについて、もう少し詳しく見ていこうと思います。前回の録画を見返したところ、コレクションに関して自分でも捉え違いがあったと感じたので、その補足も兼ねて今回はコレクションに注目していきます。
コレクションの話自体は『The Swift Programming Language』の中にも出てくる内容で、基本的なところから実装のシンプルな部分、計算量についての記載もあり、それだけでも参考になる情報は多いです。しかし今回は前回の話を踏まえて、少し深掘りしていこうと考えています。
まず、前回のZoomでストリングプロトコルに関する興味深いコメントがありましたので、その辺りから話を進めたいと思います。具体的には、ストリングプロトコルが特定の型(ストリングやサブストリング)にしか使われていないという内部規定についてです。この規定があるため、独自の型を作ってストリングとして扱うことはできないのかという話が出ました。
そこで、NSストリングを継承して独自の型を作り、それをキャストでストリング型に変換したらどうなるかという話になります。これについてのコメントがあり、とても面白い着眼点だと感じました。これを考えることで、ストリングプロトコル周りの理解が深まるかもしれません。
実際のところ、ストリングプロトコルに準拠しているのはストリングとサブストリングのみとなっていて、NSストリングはこれに準拠していません。ストリングプロトコル自体は、UTF-8ビューやUTF-16ビュー、ユニコードスカラービューなど、文字列型で一般的な機能を提供しています。
例えば、以下のようなコードを考えてみます。
let str = "Hello, world!"
let startIndex = str.startIndex
let endIndex = str.index(startIndex, offsetBy: 5)
let substring = str[startIndex..<endIndex]
このようにして取得したサブストリングはサブストリング型になります。一方、元の文字列はストリング型です。サブストリング型とストリング型が別物であるにもかかわらず、同じインターフェースで扱えるのがストリングプロトコルのメリットです。
ストリングプロトコルは、一貫性のある操作を可能にし、パフォーマンスを重視した設計となっています。そのため、ストリングやサブストリングのプレフィックスやサフィックスを取得する操作もシームレスに行えます。このように、ストリングプロトコルは文字列を表現する全ての型に対して準拠させることで、同じ操作を容易に行えるようにすることが目指されています。
以上のような形で、今回はストリングプロトコルに関連してコレクションの深掘りをしていこうと思います。次回以降も、引き続き興味深いトピックを取り上げていきますので、よろしくお願いします。 他にも、例えば count
というプロパティがありますので、わざわざメソッドを作る必要はないですが、文字列の長さを取りたいといった関数を作る場合に、例えばこれを String
型として取って、計算した結果を返すようなインターフェースを用意すると、文字列型しか渡せなくなってしまいます。具体的には、String
は渡せるけれども、ここで言う Substring
は渡せなくなるといったことが起こります。しかし、P
として StringProtocol
に準拠したものとして渡してあげると、Substring
も渡せるようになるのです。このようにすることで、String
も Substring
も受け入れられる設計ができるのです。
標準ライブラリを見ていくと、文字列を参照する場面では、こういった作り方をしているメソッドが結構存在します。これは Substring
のパフォーマンスをそのまま生かすための設計です。話が逸れましたが、もし文字列をパラメータに取る関数を作る場合、それが値の読み書きなどになると都合が悪くなることがあります。ただし、読み取りで文字数をカウントしたり、Substring
を取ったり、プレフィックスを持っているかどうか、単純な文字列比較などを行う場合は String
を取るのではなく StringProtocol
を取る形にすると、Substring
をそのまま渡すことができ、パフォーマンス向上につながるため、おすすめです。
Swift 標準ライブラリ的には、こういう書き方をしていくほうが賢明です。そうしないと、ここで必ず String
に変換する必要があり、せっかく Substring
が値を複製せず、部分文字列をメモリを余計に使わずに実装されているにもかかわらず、ちょっとした操作のために複製を作らなければいけないことになってしまいます。ジェネリクスを使うことにより、このような書き方、12行目のような書き方をするのはかなり有意義です。これを頭の片隅に置いておくだけで、良いコードが書けるかもしれません。
それはそれとして、NSString
もここに素直に渡せたほうが、より頭を複雑に使う必要がなくて便利だと思います。例えば、String
や NS
String形式の文字列があった場合、それがこちらに渡せないことになります。例えば、補完が出ていないのは単に間違えただけかもしれませんが、18行目を見るとエラーが発生しています。エラーの表現がジェネリクスを使ったからちょっと意味が変わってきたのかもしれませんが、普通に
String` 型で受け取ることも問題ありません。
とにかく、別のインターフェースがなかった場合、NS
Stringが
StringProtocol に準拠していないというエラーになります。これを見ると、
NSString
が StringProtocol
に準拠していないということが分かります。そのため、これを踏まえて話を戻すと、NS
Stringは
StringProtocol` に準拠していないという特徴があります。
さらに、前回のコメントのお話を考慮すると、この NS
Stringのインスタンスを
Stringに変換する場合、普通の
as を使って単純に型キャストしているだけのように見えますが、裏では Objective-C ブリッジが発動して全く違うインスタンスに変換されます。これによって、
NSString
だった頃の記憶はすっかり忘れられ、String
型のインスタンスになります。このような状況になると、NS
String にあったメソッドはもはや使えず、
String` 型に実装されているメソッドしか使用できなくなります。
エクステンションによって NS
String と同等の機能が追加されている場合もありますが、それは全く別のものとして扱われます。したがって、
NSString
を継承したからといっても String
とは直接関係がないという形になります。このようなことも含め、StringProtocol
による設計を考慮することが重要です。 とりあえず、String
型とSubstring
型にしか適用しちゃいけない、というのはオブジェクティブブリッジのおかげでセパレートされている都合をしっかりと条件というか要件を満たしているとも取れるな、みたいな話です。あくまでも突き詰めて考えていくという話ですが、そういう仕組みになっているんだなということを振り返ってみました。せっかくなので前回の補足として今回さらっと紹介しておきました。
String
プロトコルについては、前回このString
プロトコルがBidirectionalCollection
にも準拠しているという話になり、コレクションの定格について少し見ていきました。その際、自分があまり意識していないことが多かったなと思ったので、この辺りを改めて整理してみようと思います。
今回はコレクションのプロトコルについて、Swift標準ライブラリの実装や宣言から伺える部分を見ていこうと思います。ですので、Swiftプログラミング言語の本からは離れた感じになります。本で扱っているコレクションについてはまたの機会で見ていくことになると思います。
まず、コレクションに関連しそうな型やプロトコルを抜粋してみると、大体これぐらいがあります。もちろん他にもいろいろありますが、ここには挙げられていません。当たり前に使うところで言えば、Array
とDictionary
とSet
ですね。コレクションという言葉を聞くと、大体思い浮かぶのはこの3つだと思います。ただそれ以外にも特別な用途として備わっているものがあります。
まず、型消去をジェネリクスとして使わずに、取り扱う型が完全に何でもいい、要はAny
として扱うものとして、AnyCollection
とAnyBidirectionalCollection
、AnyRandomAccessCollection
があります。AnyCollection
は馴染みがあるかもしれませんが、それ以外はあまり使ったことがなかったと思います。しかし、コレクションの性格をしっかりと捉えた場合には、これらが明確に分かれていたほうが勝手が良いこともあると思います。
究極的にはAnyCollection
1個あれば十分かもしれませんが、その中でもAnyBidirectionalCollection
やAnyRandomAccessCollection
で扱ったからといってパフォーマンスが落ちることはないので、用意されているのは手厚い感じがします。それはそれとして、他にも特別な用途が想定されている型として、ArraySlice
やContiguousArray
、String
(文字のコレクションという意味で特別な用途)、Substring
もあります。また、Slice
(素直にArraySlice
ではなくSlice
)というのもあります。
他にも要素が1個だけに限られたコレクション(CollectionOfOne
)や、要素がないコレクション(EmptyCollection
)、順番が逆になったReversedCollection
なども存在します。この辺りは完全にパフォーマンスを意識したものになっていると思いますが、どれだけパフォーマンスに影響するのかはわかりません。それでもこういった型があるということだけは紹介しておきます。
今回は基本的な性格を示すコレクションとBidirectionalCollection
、RandomAccessCollection
について注目して見ていこうと思います。 その他にも、どれぐらいの編集ができるかという追加性格を示す、ミュータブルコレクションとレンジリプレイサブルコレクションについて軽く紹介しておきます。ミュータブルコレクションは、既にある要素を編集できるものです。「レンジリプレイサブルコレクション」というのは、編集によって範囲(インデックスの範囲)が増減する可能性のあるものを指します。要は、インデックスが維持されるけど書き換え可能なミュータブルコレクションなのか、インデックスも変化する可能性があるレンジリプレイサブルコレクションなのか、という違いです。
これはどのような影響があるかというと、例えばスライスを取ったときです。Array型でスライスを取ったとき、ミュータブルコレクションの範囲のままだと思いますが、Array型とArrayスライス型で扱うインデックスは互換性があります。具体的には、あるインデックスをArray型で使ったときに特定の値が取れる場合、それをArrayスライスで同じインデックスを使ったときにも、同じ値が取れるという保証がされています。プロトコルではこの互換性が想定されているのです。ミュータブルコレクションの場合はこの互換性が維持されます。ただし、レンジリプレイサブルコレクションの場合、インデックスが変わる可能性があり、例えば途中に要素を挿入するなどすると、同じインデックスを使っても異なる要素が取れる可能性があります。このため、プロトコルの価値観的にはインデックスが無効になるという規定があるようです。
このように、内容が変えられるだけというのと、範囲が変わるというのは大きな違いがあり、その違いによりプロトコルも分かれています。他にもLazyCollectionプロトコルのように細かく分かれているものがありますが、それらはパフォーマンス性や最適な規定の実装を考慮して細分化されています。プロトコル指向では、あらかじめどんな動きをするかをプロトコルで明示し、最適な実装を準備しておくことが目的です。特にLazyCollectionプロトコルなどは具体的な利用シナリオに合わせて分かれているようです。
また、型エイリアスとしてコレクションも用意されています。これは、シーケンスの目的に合ったものをそのまま使いつつ、コレクションとして利用できるようにするためのものです。実際、シーケンスとコレクションはとても似た性格を持っており、シーケンスプラスアルファがコレクションといった感じです。このため、シーケンスをそのまま使いつつもコレクションとして利用することがよくあります。
例えば、Arrayやzip
関数などもシーケンスで取得された型がコレクションに準拠している場合があります。zip
関数では、2つのシーケンスを取り、その型がZip2Sequence
となりますが、これはシーケンスでありつつもコレクションとして使える場合があります。具体的な例を挙げると、イナミレーティブシーケンスなどがそうかもしれません。それが具体的にどうであるかは今は思い出せませんが、シーケンス系でありながらコレクションに準拠しているケースがよくあります。
このように、Swiftのコレクションやシーケンスのプロトコルは、非常に細かく分類されており、パフォーマンスや最適な実装を重視して使い分けられています。 とりあえず、シーケンスとコレクションの違いについて説明します。これらは似たようなイメージを持っていますが、具体的に何が違うのかを見ていきましょう。
まず、シーケンスとは、ある要素が連続して存在することを意味するプロトコルです。一方で、コレクションはそのシーケンスの中で各要素にインデックスを使ってアクセスできることを保証するプロトコルです。シンプルなシーケンスであればインデックスにアクセスできないのですが、インデックスアクセスが必要な場合、コレクションが使われます。
次に、コレクションプロトコルについて見ていきます。コレクションの特徴として、要素を非破壊で複数回巡回できることが挙げられます。これがシンプルなシーケンスとの大きな違いです。非破壊であれば何回でも同じ要素を取得できますが、破壊的なアクセスを行った場合、その要素は再度取得できなくなります。
シーケンスとコレクションのもう一つの大きな違いはインデックスアクセスです。シーケンスにはインデックスアクセスが規定されていませんが、コレクションには規定されています。ただし、このインデックスアクセス性については非常に細かく規定されているわけではありません。順番にしか取れなくても、インデックスを指定した時にその要素を取得できればコレクションとして認められます。
具体例を挙げると、配列はランダムアクセスコレクションなので、多種多様なアクセスが可能です。例えば、あるシーケンスがあったとして、インデックスアクセスが可能であれば、先頭から3番目の値を取ることができます。
別の例として、リンクリストのように先頭からたどっていかないと特定の要素にアクセスできないコレクションも存在し得ます。このように、インデックスアクセスが用意されているかどうか、それが非破壊であるかが重要なポイントです。
破壊的な取得の例としては、ある要素を取得するたびにその要素が削除される場合が挙げられます。例えば、removeFirst
メソッドを使うと、先頭の要素が削除されます。この場合、もう一度先頭の要素を取得したいと思っても既に削除されているため取得できません。このような破壊的な取り方が起こらないように保証するのがコレクションの特性です。
全体として、コレクションというものは、インデックスアクセス可能であり、非破壊的に要素を取得できることが重要な特徴です。 なので、この13行目を実行したときに「0番目の要素が取れなくなる」ということはありません。これがコレクションの規定されているところであり、重要な点です。
こういったコレクションは、先ほど一覧でも表示したように、標準ライブラリ全体で幅広く使用されている点が重要です。インデックスアクセス可能という規定のおかげで、コレクション内の特定の位置にある要素にアクセスするAPIが提供されているということですね。個々の要素にアクセスする際には、ストレージ構文を使って、例えば indices
など既定の有効な範囲を渡すことでアクセス可能です。
要点を整理すると、有効な範囲というのは、スタートインデックスからエンドインデックスの手前までです。さらに言うと、indices
というプロパティがあります。これはスタートインデックスからラストインデックスまでの範囲を提供します。
ここで end
インデックスと last
インデックスの違いについても説明します。プログラミングの世界では、last
は最後の要素自体を指し、end
はその次の位置を指します。他にも sentinel
や 番兵
という言葉を聞いたことがある方もいるかと思います。これらは、例えばループの終了条件として使われます。C言語のような古い言語でも、この考え方が都合が良いのです。C文字列の場合も同様で、最後には必ず \\0
(null文字)が入ります。これが無ければ無限ループになってしまいます。
さらに専門用語で言うと、Past the end
という表現もあります。これは、エンドマーカーの役割を果たす位置を指します。
ここで、コメントを拾ってみましょう。「NSストリングのカスタムを試しましたが、見事にストリングを壊してしまいました」という体験談が寄せられています。これは、Range
をオーバーライドして、Character at index
もオーバーライドする形で行ったということですね。固定で8を返すようにして、ランダムにキャラクターを返す仕様にした結果、Str NSストリング
からストリング
にブリッジした際にコピーがされるという現象が発生したようです。
私も後で試してみますので、これは非常に興味深いことです。 まずは、今日はコレクションの話をして、この辺りは次回お話するかもしれません。ちょっと興味深いですね。まずはコレクションを進めていこうと思ったのですが、話がゆっくりすぎるので、ザクザクっと話したほうがいいと思います。このままだと話が終わらなくなってしまいますので、この辺りはこれぐらいでオッケーです。
インデックスの特徴として、先ほどお話ししたスライスについてです。スライスは元のコレクションを分割したもので、インデックスを共有します。同じインデックスであれば、どちらからでも同じ値が取れます。ただし、スライスが生成された後に元のコレクションが編集された場合には、配置が異なってくるので、その分は保証されません。さらに、次に話すのがセマンティクスについてです。この言葉は意味が多岐に渡りすぎるので、良くないなと思いつつも、他に適切な訳がなかったのでそのまま使っています。
スライスは基本的に元のコレクションをそのまま受け継ぎます。インデックスアクセスもちゃんと成立します。しかし、あくまでも元のコレクションを引き継いでいるため、元のコレクションが変更された場合には、変更された新しいフォーンに影響されるのではなく、元のコレクションがコピーされてスライスが形成されます。つまり、元のスライスは元のコレクションを表現しているのです。
要は、コピーオンライト(Copy-On-Write)がスライスでも生きているということを言っているだけですが、こういう仕組みにスライスはなっています。これはコレクションプロトコルのコメントに書いてあることで、コレクションプロトコルが求めていることと捉えて良いのではないかと思います。なので、コレクションプロトコルに準拠した場合、そのスライスはこういう定格を持つことが要求されるという感じです。
こういうふうに見ていて思ったのですが、プロトコルの説明の中にもいろんな規定があります。コンパイラでは検出できない規定が随所に求められているのです。前回の話では計算量についてもそうでした。このプロパティはオーダー1であるべきとか、そういったところがいろいろ規定されています。こういったプロトコルを規定する上でのルールは守らなければいけないのです。例えば、startIndex
みたいなAPIの要求はコンパイラがちゃんと検出してくれますが、理屈的な規定、例えばスライスはセマンティクスを継承しなければならないとか、そういうルールは出てこないです。
準拠したときのスライスはあくまでもコレクションに準拠している必要がありますが、コンパイラはセマンティクスなどの細かい約束ごとは検出できません。冒頭のコレクションのコメントにはセマンティクスを維持しなければならないといったことが書かれていますが、こういったところは当たり前にコンパイラが見逃すところです。
このような意識からコンパイラが教えてくれると思い込み、安心していた部分があります。しかし、そういった分野はたくさんあるので、改めて紹介させてもらいました。他にもコレクションについて面白い特徴がたくさんありますが、今回の時間では話しきれなかったので、次回も引き続き標準ライブラリから読み取れるコレクションについて見ていきたいと思います。
では、時間になったので今日はこれぐらいにしておきます。お疲れさまでした。ご視聴ありがとうございました。