https://youtu.be/Yv2dFE-s71Y
この前に引き続き The Swift Programming Language の脇道に逸れて、標準ライブラリーで定義されている Collection
について眺めていきます。今回は Collection
の基本概念についてを見ていってから、その特徴を特定の条件下でより際立たせた BidirectionalCollection
と RandomAccessCollection
の基本概念について確認していけたら良いなと思ってます。どうぞよろしくお願いしますね。
———————————————————————————————————————————————————— 熊谷さんのやさしい Swift 勉強会 #80
00:00 開始 01:02 定数に入れた String 型の値を変化させられる問題 01:24 プロトコルの存在意義 02:37 StringProtocol 特有の決まり事 04:26 NSString のサブクラスを String にキャストしたとき 04:40 プリミティブメソッド 06:43 定数な String の値を変化させる具体例 07:24 copy メソッドのオーバーライド 07:48 NSString の値を String の値に Objective-C ブリッジする 08:26 実装したコードの間違いを発見 10:03 実装したコードの間違いを修正 11:39 NSCopying 12:46 Objective-C ブリッジで生成した String は元のインスタンスの情報を引き継ぐ 14:05 String はブリッジ前の情報に復元可能 16:51 値型としての使用を意識した NSString 型からの継承 18:53 NSMutableString を String に振り替えたとき 21:03 NSCopying と NSMutableCopying 26:25 Collection 26:44 コレクションの巡回 27:21 文字列の count のお話 28:27 コレクションの巡回 28:47 シングルパスとマルチパス 31:15 コレクションにおけるマルチパス 31:40 コレクションの非破壊性 32:59 コンパイラーには認識できないプロトコル要件 34:48 非破壊性から導かれる性格 35:38 コレクションの範囲は有限 36:37 インデックスの順番で反復処理される 37:45 Collection プロトコルへの準拠 40:07 期待する性能 42:38 ジェネリックプログラミングでの留意点 43:59 コレクションの性能はインデックスに依存しがち 45:05 String の count について考えてみる 47:34 次回の展望 ————————————————————————————————————————————————————
Transcription & Summarize : 熊谷さんのやさしい Swift 勉強会 #80
さて、余談とは言いましたが、前回の話、もしくはこれまでの勉強会の続きとも言える内容なので、改めて仕切り直して、ここから今日は始めていく形にしましょう。もしかすると、今回は「コレクション」をやる予定でしたが、ボリュームがあるのでそこまでいかないかもしれません。この勉強会は週に3回も開催しており、ゆっくり進めていくことができる機会でもありますので、脱線して色々と見ていくのもいいと思います。
それでは、まず Foundation
をインポートします。なぜ Foundation
をインポートするのかというと、今回が初めての方もいるかもしれないので補足しますが、もともと Swift には StringProtocol
というプロトコルがあって、これは文字列型として振る舞うための豊富な機能が取り揃えられています。文字列として扱うためのインターフェースがぎっしりと備えられているプロトコルです。
例えば、BinaryInteger
というプロトコルがありますが、これは2進数表現可能な整数型をもし自分で作ったとすると、BinaryInteger
プロトコルを適用することで他の整数型と同様に扱うことができます。プロトコルは一般に足並みを整えるためのものですね。BinaryInteger
のような複雑なものはさておき、もっとシンプルなものでは Equatable
などがあります。ところが StringProtocol
に限っては、Swift 標準の String
型と Substring
型でしか使ってはいけない、という決まりがコメントで添えられています。そのため、NSString
などには適用されていませんし、独自に作った文字列型にも適用してはいけないのです。
ここから話を略しますが、独自の文字列型を NSString
から派生させて作った際、その型を Swift 標準の String
型にキャストすると、定数としてインスタンスを扱っているにもかかわらず、値が壊れてしまうことがあります。これは前回の勉強会でやった内容なので、気になる方は前のアーカイブをご覧ください。この話の続きとして、今回は NSString
のサブクラスを作る、カスタム文字列の話に進みます。
NSString
を継承したカスタム文字列を作る際には大事な約束があります。前々回あたりに、設計上必ずオーバーライドしなければならないメソッドが存在するという話をしましたが、それを考慮して作ることになります。ただ、今回はそれ抜きで進めます。本質的に問題がないので、これが影響して壊れているわけではありません。
余談ですが、前々回の話で「必ずオーバーライドしなければならないメソッド」のことをうまく言葉で表現できませんでしたが、このメソッドは「プリミティブメソッド」と呼ばれます。 なので、そのあたりを詳しく知りたいという人がいたら、「プリミティブメソッド」で検索をかけてみると多分見つかるはずです。この考え方は、NSストリングに関係した話だけではなく、もっと広いオブジェクト指向全般に関わる話です。オブジェクト指向を前提にクラスを自分で作るときに、それをさらに継承されることを想定している場合、プリミティブメソッドというものを頭に入れて設計しないと、整合性の取れた継承ができなくなる可能性があります。なので、大事な概念です。
では話を進めていきます。今回のお話は、まず、文字列の長さについてです。文字列の長さを取得し、それに基づいてランダムなアスキーコードを抽出します。また、コピーもオーバーライドされていますが、これは検証のためです。リターンを返すようになっているので、一旦置いておきますが、コピーのオーバーライドも大事なポイントですね。
では、このカスタムストリングの文字列を作成し、プリント文を実行すると、ランダムな文字列が取れてくるはずです。ただ、今回はまだ実行されていない状況です。とりあえず、8文字のデータが出ました。ランダムな文字列が取れてくるはずなんですが、先日試したときはプリントの出力とプレイグラウンドの出力が違っていましたが、特に問題はないです。このまま進めます。
次に、コードを実行すると二箇所でインプリメントされているというエラーが出ますが、特に関係なさそうなので放置しておきます。本題に戻りますが、カスタムストリング型のS
をSwift標準のString型にキャストしたときにどういう文字が出るかというと、このカスタムストリングS
を文字列型にキャストして、その結果を扱っていますが、自分の考えではここでランダムな文字列がフィックスされて、それがずっと扱われるかと思いましたが、実際には違った結果になることがありました。
もう一度同じプリント文を実行してみると、前回と違った出力になります。これに関して、コードが間違っているかもしれないので、再度実行してみます。
どうやらコピーのメソッドが複数あって、正しくないコピー方法を使っているかもしれないので、ゾーンを使ったコピーが必要です。 「コピーWithゾーン」がこれがいるんだ。だから結構重要な位置を占めてるんですね、これ。なるほど、ああ、そういう意味か、すごくよく分かりました。そうか、じゃあシンプルには壊せないんだ、これがあることがミソで、この「コピーWithゾーン」が呼ばれるから毎回変わっていくんですね。なるほど、そういうことか。
壊れた、壊れた。はい、こういうふうに22と23、ここで全然違う文字列がlet
のtostring
なのに壊れていくというお話ですね。なるほどね、そうかそうか。じゃあ自分の検証がまだ甘かったわけですね、なるほど。この「コピーWithゾーン」がミソになってくるんだ。そこから先はイメージ通りかな、多分。うん、なるほどね。
「コピーWithゾーン」、これはオブジェクティブ-Cでcopy
メソッドを呼ぶときに呼ばれるメソッドなんですけど、NSCopying
で規定されているんですね。これがp
を参照として取得したとき・・・「参照として」って言葉変だったな。オブジェクト参照の参照じゃなくて、変数参照の方の参照ね。p
は普通に読み取りってところですか。なので、p
を表示するためにインスタンスを取ってきたとき、print
文が引数として受け取ったときに「コピーWithゾーン」が呼ばれて、自分自身の値がコピーされるからという感じですね。
で、こういうふうに壊れたよっていうところが面白いですね。自分はストリングに振り返ったとき、もう関係なくなると思っていたんですけど、実は大間違いで、以前のインスタンスをストリングの中に内包しているっていうのを自分は知りませんでした。実際にストリング型は確かに内部バッファーとしていろいろ持っていますよね。そのバイト数が、文字列調ではなくて内部バッファー調になっているんです。24バイトだっけ・・・まあ調べれば出てきますけど、そういうふうに内部でちょっとした工夫がされているんですね。その都合で、この16バイトの中でいろいろ工夫しているようなんです。
これがストリングに切り替えたことによって情報が全部失われるかと思いきや、そんなことなくてね、復元ができるんです。例えば今度ストリング型をカスタムストリングにブリッジする、これ自体はできないんですけど、この互換性があるか分からないからできないんですけど、「Azure Big Remark」でキャストするとちゃんと復元できるんです。つまり、ストリング型はカスタムストリング型の存在をちゃんと覚えているということ。これがとても面白いなと思いました。
ある文字列がNSString
型でしかないときには、それをカスタムストリングにすることはできないですね。ニルになりますよね。でもこういうふうにちゃんと昔の、何というか、変換されて生まれ変わる前の記憶をちゃんと持っています。これすごいなと思って。型は何かというと、このキャストがいいんだと。
もう一個やりましょう。「Objective-Cブリッジ」でNSString
をキャストすること。これは普通のAzureでできるじゃないですか。で、ちゃんと成功しますよね。このときにU
の型は何かというと、NSString
型なわけですけど、これをもちろんカスタムストリングに変換することもできます。そして、type(of:)
で調べると、U
が何型になっているかというのをちょっと調べると、カスタムストリング型としてちゃんと取れるんですよ。
こうやってインスタンスがNSString
でキャストされることによって、昔の派生した形がちゃんと復元される。これすごいですね。ただ、これを見て思ったのは、ストリング型は値型じゃないですか。値型の性格を期待して代入時にコピーされると思うじゃないですか。でもこうやって場合によっては内側でカスタムストリング型みたいな参照型を持っていて、実際に「コピーWithゾーン」が呼ばれているということは、使われているわけですよね。
そうすると、これマルチスレッドに渡したときに衝突しないかなと心配になりますね。ここで大事になってくるのは、ストリングが危ないというよりは、Swiftを考慮してNSString
を継承する際にちゃんとスレッドセーフを考慮しないと、Swiftにブリッジした際に問題になるかもしれません。Swiftのネイティブなストリングとして扱ったときにちょっと怖いですよね。どちらかというと、カスタムストリング側の設計を注意しましょうという話になるんですけど。いずれにせよ、ストリング型の中に生存しているものに注意しておかないといけないことが分かりました。カスタムストリングが復元されるところは驚きですし、すごいなと思いました。 ちょっとこれを紹介したくて今回、時間をとったんですけど、ここからもう一つ伺えるところとして大事な点があります。NSストリングから派生しているクラスとしてNSミュータブルストリングってありますよね。それも同じことが起こるのかなと思って、ちょっと試してみたんです。どういうことかっていうと、NSミュータブルストリングはミュータブル、つまり編集できるじゃないですか。
これをSwift標準のストリングにキャストしたとき、普通に使う分には "AA" と出ます。でも、これを変更したときにどうなるんだろう? 内部で参照として持っているのであれば、ミュータブルストリングが変更されると、たとえばアペンドで "B" とかが追加されたとき、この11行目は "AAB" になるのかどうかっていうのがとても不安で試してみました。
実際にやってみると、なるんですね。代入した後で、びっくりしました。 print(s)
としてみると、結果は "AA" のままなんですよ。つまり、NSミュータブルストリングの場合には問題なく値型として振る舞えていることになります。
さっきのカスタムストリングと今回のNSミュータブルストリング、この二つの違いがどういう風に表れているかというと、これがさっきのコピーウィズゾーン、もうちょっと言うとNSコピーングですね。そこがうまく働いて、Swiftの世界でNSミュータブルストリングもちゃんと値型としての振る舞いを見せられるポイントになっているというのは、NSストリング系のコピーをやったときにはイミュータブル相当のNSストリング型が返ってくるからです。実際にはAnyで定義されていますが、返ってくるのはNSストリング相当、つまりイミュータブル相当のものです。
ミュータブルコピーの場合はNSミュータブルストリング相当のもの、要は可変のものが返ってくるという設計になっています。これは今、s
がNSミュータブルストリングですが、これが例えばNSストリングでも同様です。NSストリングの場合でも、コピーならNSストリング相当が、ミュータブルコピーならNSミュータブルストリング相当が返ってくるという感じです。
さっきのカスタムストリングの実装の話に戻りますが、コピーウィズゾーンが呼ばれるとNSミュータブルストリングでも、同様にコピーウィズゾーンが呼ばれます。コピーの場合にはコピーウィズゾーン、ミュータブルコピーの場合にはミュータブルコピーウィズゾーンが呼ばれるんです。NSミュータブルコピーングに規定されているはずです。そう、ミュータブルコピーウィズゾーンです。
ここが切り替わると、扱っていたのがNSミュータブルストリングであっても、このキャストしたときにNSコピーングのコピーウィズゾーンが呼ばれてNSストリングが得られ、その後は完全に切り離されるので、ミュータブルストリングのほうを変更しても問題ないよという動きになります。 なので、カスタムストリングをさっきのカスタムストリングみたいに作るときにも、copyWithZone
のところでランダムの文字列を固定させて、その後は何回参照されてもランダムで作らないようなインスタンスを返す設計にすると良いでしょう。これによって、ブリッジされる前はランダムにどんどん取得されるけれど、普通のストリングにキャストされたときにはその時点で固定され、値が変わらないようになります。
それを踏まえて、as String
という七行目のコードを作ると、標準のストリングに振り替えたときに値が固定されることを保証できます。ただ、もし値を変えたい場合にはどうするのかはちょっと分かりませんが、いずれにしてもそういったところに気を使う必要があるということが、前回の壊すお話を振り返ってわかりました。
ストリングプロトコルのお話はこのくらいにして、続いてコレクションのお話に移ります。前回のお話で少し難しい「セマンティクス」のような言葉が出てきましたが、これは前回の勉強会のアーカイブを見れば理解できると思いますので、今日はその続きを見ていきましょう。
現在見ているのは、Swift標準ライブラリーに入っているコレクションというプロトコルです。その意味を標準ライブラリーの定義から読んでいくというところです。続きを見ていく前に少しコメントを拾っておきます。
カスタムストリングのお話では、元のNSString
はレングスが変わっても、String
の方はタイプキャストされた時点でのカウントになっているとのことです。なるほどね。
バイディレクショナルコレクションのメソッドの複雑さの制限を満たしているのかもしれませんねという話がありました。バイディレクショナルコレクションの定義についてもこれから見ていくので、それと照らし合わせてみましょう。
ここからはコレクションの話に進みましょう。コレクションの巡回、つまりトラバーサルについてです。先頭からでなくても、順々に値を取っていくというところに視点を向けたお話です。
まず、コレクションとシーケンスは非常に似ているという話を前回しましたが、コレクションの独特なところとして「マルチパス」であることが保証されているという点が記載されています。この「マルチパス」という言葉は非常に難しいですね。いろんな経路をたどってたどり着けるという意味が真っ先に思い浮かびますが、ここでは少し違う意味になります。分野によっていろんな違う意味を持っているんですよね。コンパイラーにもこの「マルチパス」という言葉があります。 シングルパスとマルチパスについて説明します。シングルパスコンパイラーというのは、ソースコードを1回解析することでバイナリファイルが生成されるコンパイラーです。それに対して、マルチパスコンパイラーは、1回ソースコードを読み込み、例えば中間言語でコードを書き出し、その後もう一度バイナリを生成するというように、何回もパスを通すことで最終的なバイナリを生成します。このプロセスは2回に限らず、3回、4回、5回と複数回行われることがあります。
例えば、Java言語やSwiftもこのようなマルチパスの手法を取っています。Swiftにおいては、中間言語を生成し、その後さらにバイナリを作成していくプロセスになります。また、Swiftの場合は型検査(タイプチェッキング)も行います。型チェックを行うパスなど、何段階にも分けてコンパイルを進めるのが一般的です。
このマルチパスコンパイラーの考え方は、コレクションの概念にも関連しています。コレクションでのマルチパスは、一度取得した値を何度でも任意の場所から参照可能であることを意味します。これがシーケンスとの違いになります。シーケンスでは、一度取得した値が再度取得できるとは限らないため、何度でも値を取得できる保証がないのです。
前回の話とも関連しますが、シーケンスの場合、値が壊れる(破壊される)可能性があります。それに対して、コレクションは非破壊で値を取得できるため、何度でも値を取り出すことができるのです。この非破壊という特性により、コレクションはマルチパスアクセスが可能になります。
コレクションプロトコルに準拠する型を作成する際には、こうしたコンセプトを盛り込んだ設計が必要です。単にプロトコルが求めるAPIを実装するだけではなく、非破壊であることを保証し、マルチパスアクセスが可能であることを意識する必要があります。この点が自分の中で再認識した部分であり、改めて重要だと感じました。
もし、プロトコルの実装において同様の意識が薄い方が他にもいらっしゃるなら、コンパイルでは検出できないこうした制約も考慮することで、より正確なコードが書けるかもしれません。
コレクションの性質として非破壊であること、つまり値が壊れることなく同じインデックスにアクセスできることが大事です。これにより、インデックスを保存して同じインデックスに繰り返しアクセスすることも可能になります。
コレクションプロトコルに準拠する際には、非破壊であることを保障し、マルチパスアクセスが可能であることを意識するのがポイントです。最後に、コレクションのもう一つの特徴として、要素の位置が有限な範囲を形成するという点があります。これもシーケンスとの違いであり、シーケンスは長さが規定されていないのに対して、コレクションは有限であることが規定されています。 なので、一生懸命工夫して無限長のコレクションを表現した時点で、それはコレクションではなくなり、シーケンスでしかなくなるという感じですね。とにかく、コレクションは有限であることを規定することによって、contains
のような全ての要素の判定を安全に行えます。つまり、永遠と無限ループしないようになっています。反復処理するとインデックスの順番と同じ順番で得られるということも規定されています。この「規定されている」というのは、Swift標準ライブラリのコメントに書いてあるという意味です。コンパイラーが認識できないルールはコメントで盛り込むしかないので、コメントは重要であるという話をAPIデザインガイドラインのときにもしたと思います。
確かにインデックスの順番に従って反復処理できるのは当たり前のようになっていますが、それは設計が確かであれば安心して使って良いということです。
次に、コレクションに準拠する方法ですが、具体的には準拠させるための心持ちとして、要素に繰り返しアクセス可能なシーケンスを作る際には、コレクションプロトコルに準拠させると良いです。その結果、プロトコルに規定された実装によって様々な振る舞いが自動的に付与されます。
具体的な方法としては、以下の四つを実装する必要があります:
startIndex
endIndex
- インデックスによってアクセス可能なサブスクリプト
- インデックスを次に進めるための
index(after:)
これらを実装すれば、コレクションプロトコルに準拠できるようになります。最近、自分も独自の型をコレクションに準拠させることがわずらわしいと感じていましたが、実際には難しくありませんでした。おそらく、何かと混同していたのだと思います。
とにかく、コレクションプロトコルにはこの四つを満たすことで準拠できますが、実装にあたってはコレクションの基本コンセプトをちゃんと満たす必要があります。また、計算量についても規定されています。例えば、コレクションを使用する際にアクセス速度を保証するために、startIndex
、endIndex
、サブスクリプトに対してはO(1)が期待されます。もしこの期待にそぐわない場合は、コメントで文書化する必要があります。
要するに、コレクションを利用するコードが速く動くと保証するためには、O(1)の性能が大事であり、それ以外の場合は説明が必要となる、ということです。計算量を考慮する際には重要なポイントですので、注意して使う必要があります。 ただ、この決まりを見る限り、ジェネリックス、要はジェネリック関数などで型パラメーターを純粋にコレクションプロトコルだけを要求したときには、そのコレクションに準拠した型によってはオーダー1じゃない可能性もある、ということです。つまり、実際に渡ってきたインスタンスによってはオーダー1じゃないことがあるということです。それでアクセスですね。だからそういったことを保証するために、さらに制約が増えたバイディレクショナルコレクションとかランダムアクセスコレクションとか、そういったことが増えてくるのです。
なので、ジェネリックプログラミングでコレクションを縛るときには、このあたりがオーダー1じゃない可能性があるということは大事にして、忘れないようにしなければなりません。これは標準ライブラリを見ていって分かった成果というべきでしょう。
また、コレクションの操作の計算性に関する話に通じるところですが、いくつかの操作、たとえば startIndex
やサブスクリプトも期待しているだけで、実際のコレクションに依存していました。他の機能も同様で、実際の型によって性能が左右されます。これも前述しましたが、ランダムアクセスコレクションの場合には、二つのインデックスの距離をオーダー1で計算可能という制約も加わります。そうすると、コレクションのカウントというのは startIndex
と endIndex
の距離を取るものですが、この距離を計算してカウントを得られるということは、ランダムアクセスコレクションならオーダー1で計算可能です。
ここで頭の片隅に入れておいたNSストリングのカウントがオーダー1になるとは言わなかったですが、逆の見方をしていました。もしかすると、ストリングはバイディレクショナルコレクションかストリングプロトコルがバイディレクショナルコレクションなんですね。ストリング型はランダムアクセスかもしれません。しかし、とりあえずカウントはバイディレクショナルコレクションかランダムアクセスコレクションの規定の実装で提供されるので固定されるのです。違うかもしれませんが、まだ自分の知識が足りないです。
話を戻しましょう。ランダムアクセスコレクションの場合には距離が一定速度で得られるので、そのコレクションのカウントは一定速度で計算されます。しかし、ランダムアクセスコレクションでないとすると、前方向または双方向のコレクション(バイディレクショナルコレクション)は全体を巡回する必要があります。要するに、1個1個数える必要があるため、カウントはオーダーn(要素の数)になるということです。多分、バイディレクショナルコレクションはオーダーnです。それが片隅に入れた問題に通じるかもしれませんが、強調しておきます。
はい、こんな感じですね。コレクションについては、一口にコレクションと言っても、素直なコレクションかもしれないし、ランダムアクセスコレクションかもしれないし、色々なバリエーションがあります。それによって計算量は様々に依存してくることを、頭の片隅に入れてコレクションを扱わなければならないということです。
では、これで時間が来ましたので、また次回に続きます。次回はさらに制約の入ったバイディレクショナルコレクション、あとランダムアクセスコレクションも見ていきましょう。もっと深く掘り下げてコレクションを見ていく感じで進めましょう。
それでは、本日の勉強会はこれで終わりにします。お疲れ様でした。ありがとうございました。