C#のvarとtry〜catchが糞すぎる

C#3.0からはvarと書くと型を明示的に指定しなくても済む。

	var hoge = new HogeClass();

しかし例外処理をするためにこれをtry〜catchで囲みたいとする。

	try {
		var hoge = new HogeClass();
		hoge.XXX();
	} catch {
		if (hoge!=null)
			hoge.YYY();
	}

このプログラムはコンパイルが通らない。catchのなかでは変数hogeにアクセスできない。try節が終わっているため、hogeのスコープが終わってしまうのだ。仕方ないのでhogeを外部のブロックに出す。

	var hoge;
	try {
		hoge = new HogeClass();
		hoge.XXX();
	} catch {
		if (hoge!=null)
			hoge.YYY();
	}

しかしこれまたコンパイルが通らない。var hogeの部分、型推論してくれない。C#のvarは単なるsyntax sugarだと思ったほうがいいのかも知れない。そこで行頭を次のように書き直す。

	HogeClass hoge;
	try {
		hoge = new HogeClass();
		hoge.XXX();
	} catch {
		if (hoge!=null)
			hoge.YYY();
	}

これまた正しくない。new HogeClassのところで例外が発生した場合、hogeには何も代入されることなくcatch節に飛ぶ。このときhogeは未初期化のままで、未初期化の変数にアクセスしていることになる。よって、行頭は次のように書くべきだ。

	HogeClass hoge = null;
	try {
		hoge = new HogeClass();
		hoge.XXX();
	} catch {
		if (hoge!=null)
			hoge.YYY();
	}

とりあえず書けた。このように変数宣言時にnullを代入したあと、try〜catchがやってくるのは、もはや常套句である。このパターンの名前は知らないが、私は「outer scope variable(s) and try〜catch」パターンと呼んでいる。略してTryoutパターンである。以下でもそう呼ぶことにする。


このパターンをたまに初心者の人が間違えて次のように書いてしまう。

	var hoge = new HogeClass();
	try {
		hoge.XXX();
	} catch {
		if (hoge!=null)
			hoge.YYY();
	}

これだとnew HogeClass()で例外が起きるとcatchされない。何のためにtry〜catchで囲っているのかわからない。


さて、Tryoutパターンはhogeがcatchを抜けたあともスコープが生きていて気持ち悪い。仕方ないのでブロックに入れる。(このへんは好みの問題)

	{
		HogeClass hoge = null;
		try {
			hoge = new HogeClass();
			hoge.XXX();
		} catch {
			if (hoge!=null)
				hoge.YYY();
		}
	}

とてもダサくなってきた。ダサいだけじゃない。最初にHogeClassと明示的に書かなくてはならない。これはDRY(Don't Repeat Yourself)原則に反するだけではなく、もっと長い名前の複雑なクラスになった場合、とても大変だ。「インテリセンスが自動的に補完してくれるじゃないか」と言われるかも知れないが、そうとは限らない。次の例を見てみよう。

	try {
		var hoge = someClass.someMethod();
		hoge.XXX();
	} catch {
		if (hoge!=null)
			hoge.YYY();
	}

あるメソッドの返し値をvarで受け取る場合だ。この型がとても複雑でとても長いとしよう。例えばDictionary< string , Dictionary < long , List< string , Action<Handle > > > >みたいな呪文のような型名だ。こんなものは手で書きたくない。


Resharperを使っているならば、いったんvarで受け取るコードを書いたあと、マウスをクリックして「Specify type explicitly」を選べば自動的に書いてくれるのだが、Visual Studio 2010の標準機能にもそのような機能は備わっていない。



私は、varでいったん受け取るコードを書いて、そのあとResharperの「Split declaration and assignment」を選び、宣言と代入を分離したあと、「Move to outer scope」を選んで外のブロックに移動させ、そして手で「=null」と追記している。もうこの一連の動作をいままで何百回やったかわからない。


その手順自体が一発で出来て欲しい。Resharperのpluginを書けば出来るのだが、こんなもののためにpluginを書きたくないし、Resharperのpluginの仕様はバージョンアップごとに変わるので関わるのは結構嫌だ。Resharperの作者に要望を出すべきか?


そもそも、これはcode snippetなどでどうにかならないのか?あるいはtry節やcatch節が変数スコープを制限するという言語仕様自体がおかしいんじゃないか?varがきちんと型推論してくれないのがおかしいんじゃないか?などと考えてしまう。たぶん、どれも真実なのかも知れない。


いまさら言語仕様としてtry節のスコープを変更すると従来のコードが動かなくなるし、varの型推論を強化するのは言語実装的に厳しいのかも知れないので、一番簡単な解決策としてscopeを制限しない" { } "(例えば " %} "だとか)があって、次のように書けて欲しい気はする。

	try {
		var hoge = someClass.someMethod();
		hoge.XXX();
	%} catch {
		if (hoge!=null)
			hoge.YYY();
	}
	// この仕様にするならvarが代入時に例外が起きた時も
	// nullが代入されていることが保証されないといけないが。

うーん。表記は簡潔だが、インデントが "{" , "}"で揃わないのはあまりエレガントでもないような…。


ともかく、私はC#ではあと数万回ぐらいはこのTryoutパターンのお世話になる気がするんだけど…みんなはこのコードに嫌気が差したりしてないのかな…。



■ 2010/09/29 8:50追記


以下のコメントをいただきました。
返信が長文になる&とても重要な問題なのでここに追記します。

takekazuomi 2010/09/29 08:17
そもそも、constructorは、例外を投げるべきではないので、new の行をtryの外側に出す書き方でも問題ないはずだと思っています。
出来の悪いクラスを使う場合は、Tryoutパターンで書かないとだめですが、その場合は諦めます。

「constructorは、例外を投げるべきではない」<出典を忘れてしまいましたが、一般的なルールですよね?

「constructorは、例外を投げるべきではない」というのは、C++ではそういう設計でプログラムすることが多いです。


これは、C++ではGCが無いため、コンストラクタで例外が発生したとき(このときデストラクタが呼び出されない)にリソースのリークを防ぐのが難しいからです。


例えば、次のようなコードです。

void Hoge()
{
   m_my_resource1 = new Resource1();
   m_my_resource2 = new Resource2();
}

new Resource2のところで例外が発生すると、このままデストラクタは呼び出されないのでResource1がリソースリークします。これを防ぐには、スコープアウトするときにリソースを解放してくれるような(=オブジェクトをdeleteしてくれるような)ポインタを用います。例えば、boost::shared_ptrです。しかし、コンストラクタでの例外が発生した場合のためだけにm_my_resource1をboost::shared_ptrにするのはとても大掛かりです。


ゆえに、C++ではこんな設計には普通しないというだけです。


C#JavaのようにGC(garbage collection)がある言語では、このような問題は無いのでコンストラクタで例外を投げるような設計が(必ずしも)悪い設計だということはありません。.NET Frameworkにはコンストラクタで例外を投げるものがたくさんあります。例えば、よく使うものとしてStreamReaderが挙げられます。StreamReaderのコンストラクタでは引数で指定されたファイル名のファイルが存在しなければ例外を投げます。


確かにコンストラクタで例外を投げないというルールを作ればTryoutパターンは簡潔に書けますが、コンストラクタでファイルをオープン出来ないので記述が冗長になります。例えば次のように書くことになります。

var sw = new StreamReader();
sw.Open("test.txt");

例外はその場で必ず捕捉するわけではないのに対して(つまりTryoutパターンが必ずStreamReaderを使うごとに必要になるわけではないのに対して)、↑の表記はStreamReaderを使うごとに毎回必要になります。


よって、ソース全体では、このような冗長な表記になるデメリットのほうがTryoutパターンが簡潔に書けないデメリットを大きく上回ります。ゆえに、コンストラクタで例外を投げるデザインのほうが優れていると私は思います。


もう一つのコメント。

hoge 2010/09/29 07:39
スコープを広げると
var hoge = someClass.someMethod();
int piyo = 1;
の1行目で例外が発生した場合に
piyoにアクセスしていいのかという問題が出るんですよね。
「参照型のみアクセス可、未到達なら null」という仕様にするなら何とかなるか。

「スコープを広げると」というのは、「try〜catchでの変数スコープを広げると」の意味ですね。
あのスコープを広げる表記はsyntax sugarで、次のようにプリプロセスで変換されると私は考えます。

try
{
	var hoge = someClass.someMethod();
	int piyo = 1;
%} catch {
}

↓以下のように変換される。

SomeClass hoge = null;
int piyo;
try
{
	hoge = someClass.someMethod();
	piyo = 1;
} catch {
}

ゆえに、piyoに関しては、hogeのあとに宣言して、catchのなかでpiyoにアクセスしていたのでは未初期化のままアクセスしていることになりますから、これはコンパイル時に警告が出ます/出せます。ですので、この点では問題ないかと思います。


■ 2010/09/29 11:25追記

n7shi 2010/09/29 10:31
型推論を使いたいときはtryをネストさせていました。

try {
 var hoge = new HogeClass();
 try {
  hoge.XXX();
 } catch {
  hoge.YYY();
  throw;
 }
} catch {
 //共通の例外処理
}

節子、それベストアンサーや!!


tryを二重にするのは私もそうやって書くことはあります。それはそれで使えるパターンだと思います。そしてvarをexplicitに書かなくて済む、一番いい解決策だと思います。ちょっと冗長になりますが、どうしてもvarを使いたいときはその方法しかないですもんね。


■ 2010/09/30 11:00追記

	using (var sr = new StreamReader(path))
	{
		var text = sr.ReadToEnd();
	}
	Console.WriteLine(text); // なんでここでtextにアクセス出来ないの?馬鹿なの?死ぬの?

こういうケースも同様で、varを外部のスコープに移動させると型推論させられないので結局、C#のvarの型推論が甘すぎるという結論にはなると思います。


参考)
C#型推論は怠けすぎ
http://d.hatena.ne.jp/akiramei/20100929/1285759272


そんなわけで、この問題はC#の開発チームのほうにfeedbackすべきだと思いました。あとでやっておきます。