Quantcast
Channel: C#タグが付けられた新着記事 - Qiita
Viewing all articles
Browse latest Browse all 9703

C#でunsafeを使わないポインタ

$
0
0

C# その2 Advent Calendar 2019、19日目の記事です!

本記事は、どこにも使えなさそうな、でもちょっと面白いなぁみたいなのが好きな人向けに書かれています。
もしかしたらこのアドベントカレンダーに適していない話題かもしれませんが、よかったら楽しんでください。

そもそもunsafeってなんぞ?

C#にはunsafeという黒魔術があります。
unsafeというのはC#の機能の一つで、その中ではC/C++やその他低レベルプログラミング言語のようにポインタを使うことができます。
もしあなたがポインタを安全に使いこなすことができるなら、unsafeは黒魔術でもなんでもない強力な武器になりますが、C#は初心者でも安全に書けることを目標にしている部分があるので基本的には使われません。

具体的には次のように使います。

unsafe{intx=10;int*p=&x;}

この、int*という型がポインタ型を表していて、pの中にはxのアドレスが入ります。そして、

*p=20;//x -> 20

とすることで、pの中のアドレス、つまりxを書き換えることができます。

簡単にポインタの役割をまとめると、

  • xのメモリ上のアドレスを保管しておく
  • xを直接見なくてもxを変更することができる

すごく簡略化しますが、こんな感じになります(二つ目の性質は大切なので覚えておいてください)

このままではunsafeの説明だけで一つの記事が出来上がってしまうので、ここらへんで終わりにします。もっと知りたい方は、このサイトの解説がとても分かりやすいです。

何を作ったのか

結論からいうと、今説明したunsafeを使わずにポインタ(っぽい何か)を作りました。
具体的には、さっき出てきた「xを直接見なくてもxを変更することができる」という性質だけに注目して、メモリ上の場所はわからんけど読み書きできるしポインタっぽいな、みたいなシステムを構築しました。

例えば次のようなコードがあったとします。

delegatevoiddele(intx);//整数を取って何も返さないデリゲートclasscls{privateintvalue=10;//なんかの情報publicdeleget_ptr()=>(intv)=>value=v;//値を変更する関数を返すpublicvoidprint()=>Console.WriteLine($"value = {value}.");}classProgram{staticvoidMain(){clsc=newcls();c.print();//value = 10.delefunc=c.get_ptr();func(20);c.print();//value = 20.}}

delegateについても一つの記事ができてしまうので深くは触れませんが、関数の型と認識すればここでは大丈夫です。これについてもこのサイトにわかりやすい解説があります

このコードでは、xがprivateであるにも関わらず10から20に変更することができました。
メゾットが用意されているのでそれはそうなのですが、重要なのはfuncという関数を通して間接的にアクセスしたという部分です。
ここで、funcをdele型の変数として保存しておき、好きな時にfuncを実行できたらどうでしょうか。また、funcをコピーして、たくさんのオブジェクトに配ったらどうでしょうか。
それらはfuncを通じてxに読み書きできるようになります!これってポインタじゃないですか!?(たぶん違う)

この考えを発展させ、SafePointerとSafeEntityの二つのクラスを書いてみました。
SafeEntityではT型の値と、そこからポインタ型のSafePointerを生成するメゾットのセットになっていて、SafePointerではnullチェックなどを行っています。
さっきのfuncに当たるのは、SafePointerのインスタンスとなります。

classSafePointer<T>{publicdelegateTDeref();//Dereference つまり値の取得publicdelegatevoidIndir(Ta);//Indirection つまり値の書き込みpublicboolisnull{get;privateset;}=true;//値がnullかどうかprivateDeref_deref;privateIndir_indir;publicDerefderef{get{nullcheck();return_deref;}privateset{_deref=value;}}publicIndirindir{get{nullcheck();return_indir;}privateset{_indir=value;}}publicSafePointer(Derefd,Indiri){deref=d;indir=i;isnull=false;}publicSafePointer(){//値がまたないけどのポインタを作りたいときのコンストラクタisnull=true;}privatevoidnullcheck(){if(isnull)thrownewNullReferenceException("値が空です");}}classSafeEntity<T>{//ポインタを返せる実体privateTvalue;publicSafeEntity(Tx){this.value=x;}publicSafePointer<T>getrf()=>//ポインタの取得(GET ReFerence)。C/C++でいう "&" 演算子newSafePointer<T>(()=>this.value,//読みだし(Ta)=>this.value=a//書き込み);publicvoidprint(){Console.WriteLine($"value = {value}.");}}

これを使って、次のCのコードを書き直してみます。

intmain(){intx=123;int*p=&x;printf("%d",*p);//123*p=456;printf("%d",*p);//456return0;}

これをこうして...こうじゃ!
( ^ω^)
≡⊃⊂≡

staticvoidMain(string[]args){SafeEntity<int>x=newSafeEntity<int>(123);SafePointer<int>p=x.getrf();//ポインタ取得Console.WriteLine(p.deref());//123p.indir(456);Console.WriteLine(p.deref());//456}

C#でもポインタが使えましたね(にっこり)

ちなみに、C言語で言うダングリングポインタを作ろうと、次のようなコードを動かすと、

SafePointer<int>p;{SafeEntity<int>e=newSafeEntity<int>(123);p=e.getrf();}p.indir(200);Console.WriteLine(p.deref());

このコードはしっかり動作して、200と表示されます。
これは、classつまり参照型でスタックではなくヒープに実体が作られるからなのですが、例によって解説はしません。詳しく知りたい方は、ここにわかりやすく纏まっています。

実際に何か作ってみる

さて、ここまで頑張ってポインタを作ったので、何かその恩恵を享受したいところですね。
ということで、ここでは例としてリンクリストを作ってみます。機能としては最低限、

  • 要素の追加
  • 値の削除
  • 要素の表示
  • 要素のアクセス(indexerを使ってそれっぽく)

くらいがあればまぁいいでしょう。手抜き感が否めませんが、とりあえず実装してみました。

classLinkedListIterator<T>{publicSafePointer<LinkedListIterator<T>>next;//次を指すポインタTvalue;publicLinkedListIterator(Tx){next=newSafePointer<LinkedListIterator<T>>();value=x;}publicLinkedListIterator(){next=newSafePointer<LinkedListIterator<T>>();//valueは初期化されてない}publicvoidAdd(Tx){if(next.isnull)next=newSafeEntity<LinkedListIterator<T>>(newLinkedListIterator<T>(x)).getrf();elsenext.deref().Add(x);//再帰的に}publicvoidRemoveAt(inti){if(i==1)next=next.deref().next;//消す直前まで来たらif(next.isnull)thrownewIndexOutOfRangeException($"値が範囲外です {i}");//次の値がなかったらelsenext.deref().RemoveAt(i-1);}publicvoidprintForward(){//自身から前に向かって表示していくConsole.Write(value);if(!next.isnull){Console.Write(",");next.deref().printForward();return;}Console.WriteLine();}publicTthis[inti]{get{if(i==0)returnvalue;//目的のインデックスに到着if(next.isnull)thrownewIndexOutOfRangeException($"値が範囲外です。{i}");elsereturnnext.deref()[i-1];}set{if(i==0)this.value=value;if(next.isnull)thrownewIndexOutOfRangeException($"値が範囲外です。{i}");elsenext.deref()[i-1]=value;}}}classLinkedList<T>{LinkedListIterator<T>head;//0番目の要素を追加してプログラムを簡略化publicLinkedList(){head=newLinkedListIterator<T>();}publicvoidAdd(Tx){head.Add(x);}publicvoidprintAll(){head.next.deref().printForward();}publicTthis[inti]{get{returnhead[i+1];}//ヘッダの一つ分ずらして実行set{head[i+1]=value;}}publicvoidRemoveAt(inti){head.RemoveAt(i+1);}}

簡単に解説すると、
Add関数ではnextのisnullがfalseになる最後尾まで進み、新しく要素を作ってそのポインタを突っ込んでいます。もしCで実装するならmallocを使って新しく領域を確保しますが、ここでは参照型として自動でヒープに確保されることを利用し、少し気持ちの悪いコードで実現してみました。(条件次第ではガベコレに回収されそう)
RemoveAtやindexerなども同じで、範囲外の場合はちゃんとExceptionを投げるように設計しています。

ちなみに、すべて再帰を使ってるので、要素数が9930を超えたあたりからスタックオーバーフローで落ちます


(^^)


また、単純な再帰ではないのでとても遅くなります。(と思ってましたが解決方がありました。追記にあります)
検証コード

Stopwatchsw=newStopwatch();intSum=0;sw.Start();varlist=newList<int>();for(inti=0;i<5000;i++)list.Add(i);for(inti=0;i<5000;i++)Sum+=list[i];sw.Stop();Console.WriteLine("Normal list:"+sw.ElapsedTicks);sw.Reset();sw.Start();Sum=0;varlinkedList=newLinkedList<int>();for(inti=0;i<5000;i++)linkedList.Add(i);for(inti=0;i<5000;i++)Sum+=linkedList[i];sw.Stop();Console.WriteLine("My list:"+sw.ElapsedTicks);

結果
Normal list:1048
My list:21671170

はい。実に20679倍ですね。

さすがにこれでは締まらないので、すごく適当ですが書き直します。

classImprovedLinkedList<T>{LinkedListIterator<T>head;publicImprovedLinkedList(){head=newLinkedListIterator<T>();}publicvoidAdd(Tx){LinkedListIterator<T>prev=head;while(!prev.next.isnull)prev=prev.next.deref();prev.Add(x);}publicTthis[inti]{get{SafePointer<LinkedListIterator<T>>p=head.next;for(intt=0;t<i;t++)p=p.deref().next;returnp.deref().value;}}}

Normal list:1143
My list:21417001
Improved list:11419730

それでも9991倍ですが、スタックオーバーフローも回避できたし実用性は求めてないのでいいでしょう。

まとめ

今回わかったこと。

  • 普通じゃない方法でプログラミングするのは楽しい(但し実用性は考えないものとする)

もし業務でC#を使うならすでに用意されているものを使いましょう!
じゃないと、うっかり動作時間が20000倍になったり、たった5000個の要素でスタックーバーフローを起こしたり、リストに入れた値が、いつの間にかガベージコレクションにお片付けされていたなんてことが起こります。

ただ、個人的にこういう技術がとても好きなので、もっと広まってほしいとも思います。

最後になりますが、ここまで見ていただきありがとうございました。
誤字脱字・技術的な間違い・改善案など、もしありましたらコメントかTwitterまで連絡をしていただけるとありがたいです。

こんな記事が皆さんの明日の話題になれば幸いです。

追記

再帰が処理のボトルネックになる理由として、

  • スタックを使いつぶしている
  • ループに比べて再帰が遅い

というのがありました。ところで、再帰を高速化する方法の中に「末尾呼出しの最適化(tail-call optimization)」というのがあります。
これは、関数内で自身を呼び足す際、「最後に呼び出してるんだったら自分の領域はもう開放しちゃってもいいよね?」といった感じでスタックを使いつぶさずに済む技術です。
今までC#では実装されていないと思い込んでいたのですが、x64では有効になるらしく、Release,x64で実行した結果、

Normal list:1155
My list:3423799

たったの2964倍遅いだけです!速っ!

本当にスタックのおかげで高速化されてるか疑問の方もいるかと思いますが、再帰する部分に

Console.WriteLine(newStackTrace().FrameCount);

と書いて実行すればスタックをたくさん使ってないことがわかると思います。


Viewing all articles
Browse latest Browse all 9703

Trending Articles