この記事は 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
クラスがあります。それを使って書いてみましょう。
using System;
using System.Collections.Specialized;
namespace CSharp1
{
class Program
{
static void Main(string[] args)
{
// var がない
StringCollection inputs = new StringCollection(); // 型指定されたコレクション
string line;
while((line = Console.ReadLine()) != "end")
{
inputs.Add(line);
}
// 末尾からインデックス指定でループ
for (int i = inputs.Count - 1; i >= 0; i--)
{
Console.WriteLine(inputs[i]);
}
}
}
}
うん…まぁこんなもんかな。
C# 9.0
やはり LINQ が追加されたあたりで追加された機能が強い…。あと、こういう小さなコンソールアプリにおいてはトップレベルステートメントが強い。
using System;
using System.Collections.Generic;
using System.Linq;
// 無限シーケンス
static IEnumerable<string> readLines()
{
while (true) { yield return Console.ReadLine(); }
}
// end まで受け取って反転
foreach (var line in readLines().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.
public abstract class CollectionBase : ICollection, IEnumerable, IList
このクラスを継承して、特定のクラス用のコレクションを自作します。とてもめんどくさいです。
// 何か自前クラス
class Item { }
// 自前クラス用の独自コレクション
class ItemCollection : CollectionBase
{
public ItemCollection()
{
}
public ItemCollection(int capacity) : base(capacity)
{
}
public int Add(Item item)
{
return InnerList.Add(item);
}
public Item this[int index]
{
get
{
return (Item)InnerList[index];
}
set
{
InnerList[index] = value;
}
}
// 必要なメソッドを各種定義していく
}
使う側は普通に使うだけですが、定義する人は辛いです。
余談ですが、OSS の .NET 用の IDE の SharpDevelop では、型指定されたコレクションを生成するためのアイテムテンプレートがあったりした記憶があります。もう15年近く前の話なので、うろ覚えになりますが…。アイテムテンプレートが準備されるくらいにはめんどくさい作業でした。今は List<T>
って書いたら完了なのは神がかってますね。
特定のメッセージを受け取ったらイベントを発行するクラス
文字列を追加する AddMessage
というメソッドを持ったクラスで "こんにちは" か "おはようございます" か "こんばんは" が追加されたら Greet という名前のイベントを発火するようなクラスを作って。イベント引数は、その時追加されたメッセージを Message プロパティで取得できるような感じで
C# 1.0
デリゲートの取り回しもちょっとめんどくさいし、デリゲートを自分で定義するのが当たり前の世界だった。
using System;
namespace CSharp1
{
class Program
{
static void Main(string[] args)
{
MessageCollector messageCollector = new MessageCollector();
// 確かシグネチャーが同じでもデリゲートとメソッドで自動的に変換してくれなかったと思う…
// ラムダ式もないので、必ず別メソッドで定義しないといけない。
messageCollector.Greet += new GreetEventHandler(MessageCollector_Greet);
messageCollector.AddMessage("Hello");
messageCollector.AddMessage("world");
messageCollector.AddMessage("こんにちは"); // こんにちは って言ったね! と表示される
}
static void MessageCollector_Greet(object sender, GreetEventArgs args)
{
// string.Format が正義
Console.WriteLine(string.Format("{0} って言ったね!", args.Message));
}
}
class GreetEventArgs : EventArgs
{
// プロパティの定義がつらい
private string _message;
public string Message
{
get { return _message; }
}
public GreetEventArgs(string message)
{
_message = message;
}
}
// ジェネリクスが無かったので汎用的なイベントハンドラーの EventHandler<T> なんてない。
// もちろん Action<T> や Func<T, R> のようなデリゲートも定義されてないので必要があれば全部自分で定義しないといけない
delegate void GreetEventHandler(object sender, GreetEventArgs args);
class MessageCollector
{
public event GreetEventHandler Greet;
public void AddMessage(string message)
{
switch (message)
{
case "おはようございます":
case "こんにちは":
case "こんばんは":
// ?. 演算子はないので null チェックして呼ばないといけない。
if (Greet != null)
{
Greet(this, new GreetEventArgs(message));
}
break;
default:
// noop
break;
}
}
}
}
C# 9.0
細かいところで便利になってる。冗長な表現が結構消えてる。デリゲートの定義やラムダ式やらなんやら。
using System;
var messageCollector = new MessageCollector();
// ラムダ式楽。でも -= で登録解除しようとしたら、別途メソッド定義しておかないといけない。
messageCollector.Greet += (_, args) => Console.WriteLine($"{args.Message} って言ったね!");
messageCollector.AddMessage("Hello");
messageCollector.AddMessage("world");
messageCollector.AddMessage("こんにちは"); // こんにちは って言ったね! と表示される
class GreetEventArgs : EventArgs
{
public GreetEventArgs(string message)
{
Message = message;
}
// プロパティすっきり
public string Message { get; }
}
class MessageCollector
{
// ジェネリクスあるの楽だよね
public event EventHandler<GreetEventArgs> Greet;
public void AddMessage(string message)
{
switch (message)
{
case "おはようございます":
case "こんにちは":
case "こんばんは":
// ?. もあるし、 target typed new も使ってすっきり
Greet?.Invoke(this, new(message));
break;
default:
// noop
break;
}
}
}
昔はなんでもきちんと冗長に書いてた
これを書いてて思ったのは昔は何でもきちんと書いてたなぁということです。
恐らく、この冗長な書き味のせいで当時からライトにかけたスクリプト言語の流行につながっていって、C# や Java のような非スクリプト言語も型を保ったままスクリプト言語ライクにかけるようになる機能が拡充していって今のような感じになったんだろうなぁと思ってます。
非同期でインターネットからテキストのダウンロード
非同期で https://example.com
の HTML をダウンロードする感じです。
プログラムはダウンロードが終わって結果が表示されるまでは終了したらダメです。例外が出たら例外のメッセージを出す感じで。
C# 1.0
async/await なんてなかった。BeginXXXX
で始まるメソッドにコールバックを渡して呼ぶ。コールバックでは EndXXXX
というメソッドを呼んで結果を取得するという感じです。
Main メソッドは非同期処理が終わるのを待機しないといけないので…セマフォ使いました。久しぶりに使ったよ!
セマフォと、BeginXXXX
メソッドを呼んだオブジェクトをコールバックに渡さないといけないけど、コールバックに渡せるステートはオブジェクト1つだけなので、持ちまわりたいものをまとめたクラスも定義しないといけない。
クラスのプロパティには、今みたいな public string Hoge { get; set; }
のように簡単に書く方法はなく、必ずフィールドを定義して、それを自前でラップするようなプロパティを定義してあげないといけませんでした。今改めて書くとめんどくさいね!
HTTP レスポンスが取れたら、今度は Body を読む処理を非同期でやらないといけない…ワンモアコールバック…。ここでもコールバックにセマフォと、バッファーと Stream を渡したいので、それらをまとめるクラスを定義して…辛い。
ということで、以下のようなコードになりました。
using System;
using System.IO;
using System.Net;
using System.Text;
using System.Threading;
namespace CSharp1
{
class Program
{
static void Main(string[] args)
{
using (Semaphore semaphore = new Semaphore(0, 1))
{
// 非同期で読み込む処理を開始して
WebRequest req = WebRequest.Create("https://example.com");
req.BeginGetResponse(new AsyncCallback(BeginGetResponseCallback), new GetResponseState(req, semaphore));
// 終わるのを信じて待つ。信じてるので、非同期処理の先で開放漏れがあると終わらないプログラムになる。
semaphore.WaitOne();
}
}
static void BeginGetResponseCallback(IAsyncResult asyncResult)
{
GetResponseState state = (GetResponseState)asyncResult.AsyncState;
try
{
WebResponse res = state.WebRequest.EndGetResponse(asyncResult);
// 本来なら超巨大ページみたいなのに当たった時用に適当な大きさのバッファーを作っておいて
// ループぐるぐるしたほうがいいんだろうけど、非同期処理でそれをやる事を考えると辛かったので
// ギブアップした。一括読み込み!
byte[] buffer = new byte[res.ContentLength];
Stream stream = res.GetResponseStream();
stream.BeginRead(
buffer,
0,
buffer.Length,
new AsyncCallback(BeginReadCallback),
new ReadState(stream, buffer, state.Semaphore));
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
state.Semaphore.Release();
}
}
static void BeginReadCallback(IAsyncResult asyncResult)
{
ReadState state = (ReadState)asyncResult.AsyncState;
try
{
int length = 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 (Exception ex)
{
Console.WriteLine(ex.Message);
state.Semaphore.Release();
}
}
}
// BeginGetResponse のコールバックに渡したい情報 (今ならこんなクラスはレコードでよさそう)
class GetResponseState
{
private WebRequest _webRequest;
public WebRequest WebRequest
{
get
{
return _webRequest;
}
}
private Semaphore _semaphore;
public Semaphore Semaphore
{
get
{
return _semaphore;
}
}
public GetResponseState(WebRequest req, Semaphore sem)
{
_webRequest = req;
_semaphore = sem;
}
}
// BeginRead のコールバックに渡したい情報
class ReadState
{
private Stream _stream;
public Stream Stream
{
get
{
return _stream;
}
}
private byte[] _buffer;
public byte[] Buffer
{
get
{
return _buffer;
}
}
private Semaphore _semaphore;
public Semaphore Semaphore
{
get
{
return _semaphore;
}
}
public ReadState(Stream stream, byte[] buffer, Semaphore semaphore)
{
_stream = stream;
_buffer = buffer;
_semaphore = semaphore;
}
}
}
長い…、実行すると example.com の HTML が標準出力に表示されます。
C# 9.0
async/await 神かよ…
using System;
using System.Net.Http;
var client = new HttpClient();
try
{
// 神かよ…
var body = await client.GetStringAsync("https://example.com");
Console.WriteLine(body);
}
catch(Exception ex)
{
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 で書いてて心が折れたので妥協します。
ということで以下のような感じになりました。
using System;
using System.IO;
using System.Net;
using System.Text;
using System.Threading;
namespace CSharp1
{
class Program
{
// このスコープにセマフォもってくるの負けた気がする
private static Semaphore semaphore = new Semaphore(0, 1);
static void Main(string[] args)
{
// TLS1.2
ServicePointManager.Expect100Continue = true;
ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;
using (semaphore)
{
// 進捗報告と完了報告はイベントで受け取る
Downloader d = new Downloader();
d.TitleTagDetected += new TitleTagDetectedEventHandler(Downloader_TitleTagDetected);
d.Completed += new EventHandler(Downloader_Completed);
d.Start();
// イベントハンドラーの先でReleaseが呼ばれることを信じて待つ
semaphore.WaitOne();
}
}
private static void Downloader_Completed(object sender, EventArgs e)
{
// 完了したのでリリース
semaphore.Release();
}
private static void Downloader_TitleTagDetected(object sender, TitleTagDetectedEventArgs args)
{
// 進捗を表示
Console.WriteLine(args.Url);
Console.WriteLine(args.Title);
}
}
class TitleTagDetectedEventArgs : EventArgs
{
private string _url;
public string Url { get { return _url; } }
private string _title;
public string Title { get { return _title; } }
public TitleTagDetectedEventArgs(string url, string title)
{
_url = url;
_title = title;
}
}
delegate void TitleTagDetectedEventHandler(object sender, TitleTagDetectedEventArgs args);
class Downloader
{
public event TitleTagDetectedEventHandler TitleTagDetected;
public event EventHandler Completed;
public void Start()
{
// スレッドプールで処理をやって呼び出し元をブロックしない作戦
ThreadPool.QueueUserWorkItem(new WaitCallback(StartImpl));
}
private void StartImpl(object state)
{
// ファイル名は urllist.txt 決め打ちでとりあえず。
using (StreamReader sr = new StreamReader("urllist.txt"))
using (WebClient client = new WebClient())
{
// 同期処理のオンパレード
client.Encoding = Encoding.UTF8;
for (string url; (url = sr.ReadLine()) != null;)
{
string data = client.DownloadString(url);
foreach (string line in data.Split('\n'))
{
// 雑に対応
if (line.Contains("<title>"))
{
// title タグがあったらイベント発行
TitleTagDetectedEventHandler h = TitleTagDetected;
if (h != null)
{
h(this, new TitleTagDetectedEventArgs(url, line));
}
}
}
}
}
// 終了時も通知
EventHandler completedHandlers = 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 待ちでスレッドを無駄にブロックはしない。
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
// await foreach 便利
await foreach (var (title, url) in Downloader.GetTitlesAsync())
{
Console.WriteLine(url);
Console.WriteLine(title);
}
class Downloader
{
private static HttpClient _client = new();
// 複数個の値を非同期で返すのもお手の物
public static async IAsyncEnumerable<(string title, string url)> GetTitlesAsync()
{
var urlList = await File.ReadAllLinesAsync("urllist.txt");
foreach (var url in urlList)
{
var body = await _client.GetStringAsync(url);
// 雑に title タグのある行を取得
var title = body.Split('\n').FirstOrDefault(x => x.Contains("<title>"));
if (title != null)
{
// title タグの行と url を返す
yield return (title, url);
}
}
}
}
やっぱ非同期処理系は圧倒的に便利になってますね。
入力 2 つを整数かどうか判定して、さらに偶数かどうかも判定してメッセージを出しわける
ユーザーが入力した 2 つの文字列を、それぞれ整数かどうか判定し条件に応じて以下のようにメッセージを出しわける。
条件 | メッセージ |
---|---|
両方整数でかつ両方偶数 | 両方偶数!!入力した値は XX と XX ですね! |
両方整数 | 入力した値は XX と XX ですね! |
どちらかが整数 | おしいね… |
どちらも整数ではない | まだまだだね |
今回は非常にシンプルだけど、イメージとしては特定のデータが来た時にルールによって振り分けるありがちな処理です。
C# 1.0
愚直に書いてみた。おしいね…ルートは共通化できたかもしれないけどいいや。
using System;
namespace CSharp1
{
class Program
{
// int.TryParse は 1.0 の頃にはなかった
static bool TryParse(string input, out int result)
{
try
{
result = int.Parse(input);
return true;
}
catch
{
result = 0;
return false;
}
}
static void Main(string[] args)
{
string input1 = Console.ReadLine();
string input2 = Console.ReadLine();
int parsedValue1;
int parsedValue2;
bool isInput1Valid = TryParse(input1, out parsedValue1);
bool isInput2Valid = TryParse(input2, out parsedValue2);
// 素直に if で分岐
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 式で分岐が簡単に出来るようになってるのが強い。
ちなみに下のコードは、自分が書いたコードを岩永さんに手直ししてもらいました。多謝!
using System;
var x1 = parseOrNull(Console.ReadLine());
var x2 = parseOrNull(Console.ReadLine());
var message = (x1, x2) switch
{
(int v1, int v2) when isEven(v1) && isEven(v2) => $"両方偶数!!入力した値は {v1} と {v2} ですね!",
(int v1, int v2) => $"入力した値は {v1} と {v2} ですね!",
(int, null) or (null, int) => "おしいね…",
(null, null) => "まだまだだね", // 網羅性チェックのためにあえて null, null。条件漏れ防いでる
};
Console.WriteLine(message);
bool isEven(int value) => (value % 2) == 0;
// input が NRT 対応で string?
// ひそかに target-typed 条件演算子で value : null が成り立ってる
int? parseOrNull(string? input) => int.TryParse(input, out var value) ? value : null;
if 文になくて switch 式にある特徴としてきちんと条件が網羅されているかどうか、抜け漏れがないかというのをコンパイラがチェックしてくれるというものがあります。
上のプログラムでも、最後を _ => "まだまだだね"
にせずに明示的に (null, null) => "まだまだだね"
にすることで、ちゃんと条件に抜け漏れがないということをコンパイラにチェックしてもらってます(岩永さん談)。
なるほどね switch 式便利。
まとめ
ということで、昔を思い出すために C# 1.0 の頃と C# 9.0 の頃とで同じお題をもとにいくつか処理を書いてみました。
ぱっと書いた感想としては、ジェネリクス、LINQ, async/await あたりが結構大きな変化だったのかなと感じました。
他にも細かな ?.
演算子や、今回は登場していない ??
や ??=
などのような null の時によくやる処理を短く書けるようにしてくれていたり、タプル(値型のほうのタプル)も疑似的に戻り値を複数にしたりとか出来たりなどなど、沢山ありますが今回 C# 1.0 の機能だけに絞ってコードを書いてみて、暫くは書きたくないかな…と思うくらいには不便でした。
ということで 12/2 のアドベントカレンダーは以上になります。ありがとうございました。