この記事は C# Advent Calendar 2020の 2 日目の記事です。1 日目は @RyotaMurohoshiさんの C# 9.0で加わったC# Source Generatorと、それで作ったValueObjectGeneratorの紹介でした。
私の記事では、.NET Framework 1.0 の頃の C# 1.0 と今の .NET 5 時代の C# 9.0 で同じお題をもとにプログラムを書いてみて比べてみようと思います。これを書くにあたって事前に xin9le さんと 岩永さんに色々見てもらいました!感謝!
ではやってみましょう!
記事を書く前の感覚では LINQ の有無と async/await の有無が大きいだろうな…と思ってます。
プロジェクトの設定
Windows 10 に .NET Framework 1.1 SDK を入れようと思えば入れることが出来るみたいなのですが、サポートが切れた SDK を入れる勇気がなかったのでサポートされている .NET Framework 4.5 系のプロジェクトで LangVersion 1 を設定したものを .NET Framework 1.0 の頃の気持ちで書いていこうと思います。なので無意識に .NET Framework 1.X の時代には無かった便利クラスを使ってしまっている可能性がありますが、気持ちとしては使わないようにしています。
C# 9.0 は .NET 5 のプロジェクトにします。
end と入力するまで文字列を受け取って最後に入力した文字列から表示する
こんな感じのものをイメージしています。
> aaaa
> bbbb
> cccc
> end
> cccc # ここから下が出力
> bbbb
> aaaa
C# 1.0
C# 1.0 の頃にはジェネリクスはありません。当時は全部 object 型で格納する System.Collections.ArrayList
を使います。ただ、それじゃぁあんまりなので System.Collections.Specialized
名前空間に型指定されたコレクションがいくつかあります。今回のような文字列は、よく使われるので StringCollection
クラスがあります。それを使って書いてみましょう。
usingSystem;usingSystem.Collections.Specialized;namespaceCSharp1{classProgram{staticvoidMain(string[]args){// var がないStringCollectioninputs=newStringCollection();// 型指定されたコレクションstringline;while((line=Console.ReadLine())!="end"){inputs.Add(line);}// 末尾からインデックス指定でループfor(inti=inputs.Count-1;i>=0;i--){Console.WriteLine(inputs[i]);}}}}
うん…まぁこんなもんかな。
C# 9.0
やはり LINQ が追加されたあたりで追加された機能が強い…。あと、こういう小さなコンソールアプリにおいてはトップレベルステートメントが強い。
usingSystem;usingSystem.Collections.Generic;usingSystem.Linq;// 無限シーケンスstaticIEnumerable<string>readLines(){while(true){yieldreturnConsole.ReadLine();}}// end まで受け取って反転foreach(varlineinreadLines().TakeWhile(x=>x!="end").Reverse()){Console.WriteLine(line);}
ローカル関数で Console.ReadLine()
を無限シーケンスにして、あとは LINQ で TakeWhile で end まで取得して反転して foreach でループを回して表示しています。LINQ 以降は色々なものを IEnumerable<T>
にして LINQ で処理するという考え方でプログラムが組めるようになりました。今回も標準入力を IEnumerable<string>
として扱って LINQ で処理することで簡単にかけてますね。
独自型の型指定されたコレクション
このお題で出てきた StringCollection
のような型指定されたコレクションですが自作のクラスに関してはもちろん型指定されたクラスは用意されていません。なので自作します。自作といってもコレクションの機能を全部作りこむのは大変なので、System.Collections.CollectionBase
というクラスがあって、基本的にはこれを継承して作ります。
このクラスの定義部分は以下のような感じになっています。型指定されたコレクション用ってちゃんと書いてありますね。
//// 概要:// Provides the abstract base class for a strongly typed collection.publicabstractclassCollectionBase:ICollection,IEnumerable,IList
このクラスを継承して、特定のクラス用のコレクションを自作します。とてもめんどくさいです。
// 何か自前クラスclassItem{}// 自前クラス用の独自コレクションclassItemCollection:CollectionBase{publicItemCollection(){}publicItemCollection(intcapacity):base(capacity){}publicintAdd(Itemitem){returnInnerList.Add(item);}publicItemthis[intindex]{get{return(Item)InnerList[index];}set{InnerList[index]=value;}}// 必要なメソッドを各種定義していく}
使う側は普通に使うだけですが、定義する人は辛いです。
余談ですが、OSS の .NET 用の IDE の SharpDevelopでは、型指定されたコレクションを生成するためのアイテムテンプレートがあったりした記憶があります。もう15年近く前の話なので、うろ覚えになりますが…。アイテムテンプレートが準備されるくらいにはめんどくさい作業でした。今は List<T>
って書いたら完了なのは神がかってますね。
特定のメッセージを受け取ったらイベントを発行するクラス
文字列を追加する AddMessage
というメソッドを持ったクラスで "こんにちは" か "おはようございます" か "こんばんは" が追加されたら Greet という名前のイベントを発火するようなクラスを作って。イベント引数は、その時追加されたメッセージを Message プロパティで取得できるような感じで
C# 1.0
デリゲートの取り回しもちょっとめんどくさいし、デリゲートを自分で定義するのが当たり前の世界だった。
usingSystem;namespaceCSharp1{classProgram{staticvoidMain(string[]args){MessageCollectormessageCollector=newMessageCollector();// 確かシグネチャーが同じでもデリゲートとメソッドで自動的に変換してくれなかったと思う…// ラムダ式もないので、必ず別メソッドで定義しないといけない。messageCollector.Greet+=newGreetEventHandler(MessageCollector_Greet);messageCollector.AddMessage("Hello");messageCollector.AddMessage("world");messageCollector.AddMessage("こんにちは");// こんにちは って言ったね! と表示される}staticvoidMessageCollector_Greet(objectsender,GreetEventArgsargs){// string.Format が正義Console.WriteLine(string.Format("{0} って言ったね!",args.Message));}}classGreetEventArgs:EventArgs{// プロパティの定義がつらいprivatestring_message;publicstringMessage{get{return_message;}}publicGreetEventArgs(stringmessage){_message=message;}}// ジェネリクスが無かったので汎用的なイベントハンドラーの EventHandler<T> なんてない。// もちろん Action<T> や Func<T, R> のようなデリゲートも定義されてないので必要があれば全部自分で定義しないといけないdelegatevoidGreetEventHandler(objectsender,GreetEventArgsargs);classMessageCollector{publiceventGreetEventHandlerGreet;publicvoidAddMessage(stringmessage){switch(message){case"おはようございます":case"こんにちは":case"こんばんは":// ?. 演算子はないので null チェックして呼ばないといけない。if(Greet!=null){Greet(this,newGreetEventArgs(message));}break;default:// noopbreak;}}}}
C# 9.0
細かいところで便利になってる。冗長な表現が結構消えてる。デリゲートの定義やラムダ式やらなんやら。
usingSystem;varmessageCollector=newMessageCollector();// ラムダ式楽。でも -= で登録解除しようとしたら、別途メソッド定義しておかないといけない。messageCollector.Greet+=(_,args)=>Console.WriteLine($"{args.Message}って言ったね!");messageCollector.AddMessage("Hello");messageCollector.AddMessage("world");messageCollector.AddMessage("こんにちは");// こんにちは って言ったね! と表示されるclassGreetEventArgs:EventArgs{publicGreetEventArgs(stringmessage){Message=message;}// プロパティすっきりpublicstringMessage{get;}}classMessageCollector{// ジェネリクスあるの楽だよねpubliceventEventHandler<GreetEventArgs>Greet;publicvoidAddMessage(stringmessage){switch(message){case"おはようございます":case"こんにちは":case"こんばんは":// ?. もあるし、 target typed new も使ってすっきりGreet?.Invoke(this,new(message));break;default:// noopbreak;}}}
昔はなんでもきちんと冗長に書いてた
これを書いてて思ったのは昔は何でもきちんと書いてたなぁということです。
非同期でインターネットからテキストのダウンロード
非同期で https://example.com
の HTML をダウンロードする感じです。
プログラムはダウンロードが終わって結果が表示されるまでは終了したらダメです。例外が出たら例外のメッセージを出す感じで。
C# 1.0
async/await なんてなかった。BeginXXXX
で始まるメソッドにコールバックを渡して呼ぶ。コールバックでは EndXXXX
というメソッドを呼んで結果を取得するという感じです。
Main メソッドは非同期処理が終わるのを待機しないといけないので…セマフォ使いました。久しぶりに使ったよ!
セマフォと、BeginXXXX
メソッドを呼んだオブジェクトをコールバックに渡さないといけないけど、コールバックに渡せるステートはオブジェクト1つだけなので、持ちまわりたいものをまとめたクラスも定義しないといけない。
クラスのプロパティには、今みたいな public string Hoge { get; set; }
のように簡単に書く方法はなく、必ずフィールドを定義して、それを自前でラップするようなプロパティを定義してあげないといけませんでした。今改めて書くとめんどくさいね!
HTTP レスポンスが取れたら、今度は Body を読む処理を非同期でやらないといけない…ワンモアコールバック…。ここでもコールバックにセマフォと、バッファーと Stream を渡したいので、それらをまとめるクラスを定義して…辛い。
ということで、以下のようなコードになりました。
usingSystem;usingSystem.IO;usingSystem.Net;usingSystem.Text;usingSystem.Threading;namespaceCSharp1{classProgram{staticvoidMain(string[]args){using(Semaphoresemaphore=newSemaphore(0,1)){// 非同期で読み込む処理を開始してWebRequestreq=WebRequest.Create("https://example.com");req.BeginGetResponse(newAsyncCallback(BeginGetResponseCallback),newGetResponseState(req,semaphore));// 終わるのを信じて待つ。信じてるので、非同期処理の先で開放漏れがあると終わらないプログラムになる。semaphore.WaitOne();}}staticvoidBeginGetResponseCallback(IAsyncResultasyncResult){GetResponseStatestate=(GetResponseState)asyncResult.AsyncState;try{WebResponseres=state.WebRequest.EndGetResponse(asyncResult);// 本来なら超巨大ページみたいなのに当たった時用に適当な大きさのバッファーを作っておいて// ループぐるぐるしたほうがいいんだろうけど、非同期処理でそれをやる事を考えると辛かったので// ギブアップした。一括読み込み!byte[]buffer=newbyte[res.ContentLength];Streamstream=res.GetResponseStream();stream.BeginRead(buffer,0,buffer.Length,newAsyncCallback(BeginReadCallback),newReadState(stream,buffer,state.Semaphore));}catch(Exceptionex){Console.WriteLine(ex.Message);state.Semaphore.Release();}}staticvoidBeginReadCallback(IAsyncResultasyncResult){ReadStatestate=(ReadState)asyncResult.AsyncState;try{intlength=state.Stream.EndRead(asyncResult);if(length!=state.Buffer.Length){Console.WriteLine("何かデータうまく読めなかった");state.Semaphore.Release();return;}// UTF-8 決め打ちにしました…Console.WriteLine(Encoding.UTF8.GetString(state.Buffer));state.Semaphore.Release();}catch(Exceptionex){Console.WriteLine(ex.Message);state.Semaphore.Release();}}}// BeginGetResponse のコールバックに渡したい情報 (今ならこんなクラスはレコードでよさそう)classGetResponseState{privateWebRequest_webRequest;publicWebRequestWebRequest{get{return_webRequest;}}privateSemaphore_semaphore;publicSemaphoreSemaphore{get{return_semaphore;}}publicGetResponseState(WebRequestreq,Semaphoresem){_webRequest=req;_semaphore=sem;}}// BeginRead のコールバックに渡したい情報classReadState{privateStream_stream;publicStreamStream{get{return_stream;}}privatebyte[]_buffer;publicbyte[]Buffer{get{return_buffer;}}privateSemaphore_semaphore;publicSemaphoreSemaphore{get{return_semaphore;}}publicReadState(Streamstream,byte[]buffer,Semaphoresemaphore){_stream=stream;_buffer=buffer;_semaphore=semaphore;}}}
長い…、実行すると example.com の HTML が標準出力に表示されます。
C# 9.0
async/await 神かよ…
usingSystem;usingSystem.Net.Http;varclient=newHttpClient();try{// 神かよ…varbody=awaitclient.GetStringAsync("https://example.com");Console.WriteLine(body);}catch(Exceptionex){Console.WriteLine(ex.Message);}
async/await で同期処理と同じように非同期処理が書けるのは神がかってますね。あと HttpClient クラスが便利。C# 9 では、JSON を送受信することを想定したメソッドとかも追加されていて、より使いやすくなってます。
async/await に至るまで
.NET Framework 1.0 が出た当初は、コアが複数あるパソコンは一般人が使うものとしてはレアで、モンスターマシンを組むような人の中でも稀に物理的に CPU が 2 個ついてるものがあるかもしれないとかくらいだった気がします。
なので、今回のような IO 待ちに非同期処理を行うのは当時も効果はあったと思うのですが、得られるメリットに対してコードが凄い大変なので、そこまで非同期処理がカジュアルに書かれる感じではなかった印象です。
.NET framework 2.0 で重たい計算処理をするような処理を簡単に書くための BackgroundWorker というクラスが提供されましたが、これも IO 待ちの場合は最適な選択なのかどうかは微妙な気がする。
その後 Task などが追加されメソッドチェーンで非同期処理を書けるようになったあとに async/await が追加されて今のような形になりました。今じゃぁカジュアルに非同期処理かけていいね!
テキストファイルに書かれた URL のリストからサイトのタイトルタグの行を取得
こちらのお題ですが、出来れば GUI アプリケーションから呼んだ時に IO 待ちで UI がブロックされるようなことは避けてほしいという考慮を求められているという感じで行こうと思います。ここで書くのはコンソールアプリですが。
ついでに、全サイトを読み込んでから結果を通知ではなく、なるべく 1 サイトのソースをダウンロードするたびに結果が取れるようにしてほしいという感じです。Downloader というクラスにこの機能を実装していきます。
C# 1.0
C# 1.0 で呼び出し元スレッドをブロックしないようにしようと思ったら別スレッドで処理する方法をとっちゃうと思います。例えそれが IO 待ちで計算が重たいとかではないとしても…。1 つ前の非同期でのダウンロードを C# 1.0 で書いてて心が折れたので妥協します。
ということで以下のような感じになりました。
usingSystem;usingSystem.IO;usingSystem.Net;usingSystem.Text;usingSystem.Threading;namespaceCSharp1{classProgram{// このスコープにセマフォもってくるの負けた気がするprivatestaticSemaphoresemaphore=newSemaphore(0,1);staticvoidMain(string[]args){// TLS1.2ServicePointManager.Expect100Continue=true;ServicePointManager.SecurityProtocol=SecurityProtocolType.Tls12;using(semaphore){// 進捗報告と完了報告はイベントで受け取るDownloaderd=newDownloader();d.TitleTagDetected+=newTitleTagDetectedEventHandler(Downloader_TitleTagDetected);d.Completed+=newEventHandler(Downloader_Completed);d.Start();// イベントハンドラーの先でReleaseが呼ばれることを信じて待つsemaphore.WaitOne();}}privatestaticvoidDownloader_Completed(objectsender,EventArgse){// 完了したのでリリースsemaphore.Release();}privatestaticvoidDownloader_TitleTagDetected(objectsender,TitleTagDetectedEventArgsargs){// 進捗を表示Console.WriteLine(args.Url);Console.WriteLine(args.Title);}}classTitleTagDetectedEventArgs:EventArgs{privatestring_url;publicstringUrl{get{return_url;}}privatestring_title;publicstringTitle{get{return_title;}}publicTitleTagDetectedEventArgs(stringurl,stringtitle){_url=url;_title=title;}}delegatevoidTitleTagDetectedEventHandler(objectsender,TitleTagDetectedEventArgsargs);classDownloader{publiceventTitleTagDetectedEventHandlerTitleTagDetected;publiceventEventHandlerCompleted;publicvoidStart(){// スレッドプールで処理をやって呼び出し元をブロックしない作戦ThreadPool.QueueUserWorkItem(newWaitCallback(StartImpl));}privatevoidStartImpl(objectstate){// ファイル名は urllist.txt 決め打ちでとりあえず。using(StreamReadersr=newStreamReader("urllist.txt"))using(WebClientclient=newWebClient()){// 同期処理のオンパレードclient.Encoding=Encoding.UTF8;for(stringurl;(url=sr.ReadLine())!=null;){stringdata=client.DownloadString(url);foreach(stringlineindata.Split('\n')){// 雑に対応if(line.Contains("<title>")){// title タグがあったらイベント発行TitleTagDetectedEventHandlerh=TitleTagDetected;if(h!=null){h(this,newTitleTagDetectedEventArgs(url,line));}}}}}// 終了時も通知EventHandlercompletedHandlers=Completed;if(completedHandlers!=null){completedHandlers(this,EventArgs.Empty);}}}}
urllist.txt は以下のような内容のテキストです。
https://example.com
https://ufcpp.net
https://github.com
実行してみましょう。
https://example.com
<title>Example Domain</title>
https://ufcpp.net
<title>++C++; // 未確認飛行 C</title>
https://github.com
<title>GitHub: Where the world builds software ・ GitHub</title>
ちゃんと取れてますね。
C# 9.0
こういう非同期処理で複数の値を返すようなものは非同期イテレーターが活きてくる。あと、ちゃんと IO 待ちは IO 待ちでスレッドを無駄にブロックはしない。
usingSystem;usingSystem.Collections.Generic;usingSystem.IO;usingSystem.Linq;usingSystem.Net.Http;// await foreach 便利awaitforeach(var(title,url)inDownloader.GetTitlesAsync()){Console.WriteLine(url);Console.WriteLine(title);}classDownloader{privatestaticHttpClient_client=new();// 複数個の値を非同期で返すのもお手の物publicstaticasyncIAsyncEnumerable<(stringtitle,stringurl)>GetTitlesAsync(){varurlList=awaitFile.ReadAllLinesAsync("urllist.txt");foreach(varurlinurlList){varbody=await_client.GetStringAsync(url);// 雑に title タグのある行を取得vartitle=body.Split('\n').FirstOrDefault(x=>x.Contains("<title>"));if(title!=null){// title タグの行と url を返すyieldreturn(title,url);}}}}
やっぱ非同期処理系は圧倒的に便利になってますね。
入力 2 つを整数かどうか判定して、さらに偶数かどうかも判定してメッセージを出しわける
ユーザーが入力した 2 つの文字列を、それぞれ整数かどうか判定し条件に応じて以下のようにメッセージを出しわける。
条件 | メッセージ |
---|---|
両方整数でかつ両方偶数 | 両方偶数!!入力した値は XX と XX ですね! |
両方整数 | 入力した値は XX と XX ですね! |
どちらかが整数 | おしいね… |
どちらも整数ではない | まだまだだね |
今回は非常にシンプルだけど、イメージとしては特定のデータが来た時にルールによって振り分けるありがちな処理です。
C# 1.0
愚直に書いてみた。おしいね…ルートは共通化できたかもしれないけどいいや。
usingSystem;namespaceCSharp1{classProgram{staticvoidMain(string[]args){stringinput1=Console.ReadLine();stringinput2=Console.ReadLine();intparsedValue1;intparsedValue2;boolisInput1Valid=int.TryParse(input1,outparsedValue1);boolisInput2Valid=int.TryParse(input2,outparsedValue2);if(isInput1Valid){if(isInput2Valid){if(parsedValue1%2==0&&parsedValue2%2==0){Console.WriteLine(string.Format("両方偶数!!入力した値は {0} と {1} ですね!",parsedValue1,parsedValue2));}else{Console.WriteLine(string.Format("入力した値は {0} と {1} ですね!",parsedValue1,parsedValue2));}}else{Console.WriteLine("おしいね…");}}else{if(isInput2Valid){Console.WriteLine("おしいね…");}else{Console.WriteLine("まだまだだね");}}}}}
C# 9.0
switch 式でパターンマッチが使えるようになったおかげで、事前に判断に必要な情報を整理しておいて、switch 式で分岐が簡単に出来るようになってるのが強い。
ちなみに下のコードは、自分が書いたコードを岩永さんに手直ししてもらいました。多謝!
usingSystem;varx1=parseOrNull(Console.ReadLine());varx2=parseOrNull(Console.ReadLine());varmessage=(x1,x2)switch{(intv1,intv2)whenisEven(v1)&&isEven(v2)=>$"両方偶数!!入力した値は {v1}と {v2}ですね!",(intv1,intv2)=>$"入力した値は {v1}と {v2}ですね!",(int,null)or(null,int)=>"おしいね…",(null,null)=>"まだまだだね",// 網羅性チェックのためにあえて null, null。条件漏れ防いでる};Console.WriteLine(message);boolisEven(intvalue)=>(value%2)==0;// input が NRT 対応で string?// ひそかに target-typed 条件演算子で value : null が成り立ってるint?parseOrNull(string?input)=>int.TryParse(input,outvarvalue)?value:null;
if 文になくて switch 式にある特徴としてきちんと条件が網羅されているかどうか、抜け漏れがないかというのをコンパイラがチェックしてくれるというものがあります。
上のプログラムでも、最後を _ => "まだまだだね"
にせずに明示的に (null, null) => "まだまだだね"
にすることで、ちゃんと条件に抜け漏れがないということをコンパイラにチェックしてもらってます(岩永さん談)。
なるほどね switch 式便利。
まとめ
ということで、昔を思い出すために C# 1.0 の頃と C# 9.0 の頃とで同じお題をもとにいくつか処理を書いてみました。
ぱっと書いた感想としては、ジェネリクス、LINQ, async/await あたりが結構大きな変化だったのかなと感じました。
他にも細かな ?.
演算子や、今回は登場していない ??
や ??=
などのような null の時によくやる処理を短く書けるようにしてくれていたり、タプル(値型のほうのタプル)も疑似的に戻り値を複数にしたりとか出来たりなどなど、沢山ありますが今回 C# 1.0 の機能だけに絞ってコードを書いてみて、暫くは書きたくないかな…と思うくらいには不便でした。
ということで 12/2 のアドベントカレンダーは以上になります。ありがとうございました。