今でもこの記事にコメントを頂くことがあり大変有難いのですが、実は、現在はまたもや異なる見解、というか捉え方をしています。(何度もすみません…)
といっても、「業務エラーは戻り値で表すべき」という考えになったというわけではありません。
以前は業務エラーは「戻り値」で表すべきか「例外」で表すべきかについて悩んでいましたが、今の僕は、そもそも業務エラーを表すのはこのどちらでもないと考えています。
まず、
- 戻り値は、メソッドが結果を返すためのものです
- 例外は、メソッドが例外的事象を通知するためのものです
そして、これが全てです。
戻り値も例外も、業務エラーを表すためのものではないのです。
これだけじゃイマイチ伝わらないかもしれませんね。
ではここから、ドメイン駆動設計 (DDD) という設計思想を例に取ってみます。
書籍「ドメイン駆動」によると、DDD プロジェクトの典型的なレイヤ分割では、次の 4 つのレイヤに分割されるとしています。(直接的にはあまり深く語られていないので、以下は僕なりの解釈です。)
[ User Interface レイヤ ]
その名の通り UI に関するレイヤです。MVC における View に相当します。
テキストボックスの入力値を取得したり設定したり、ラベルの表示・非表示を切り替えたり、といった直接的な UI 操作のみを行います。
[ Application レイヤ ]
アプリケーションとしての動作に関するレイヤです。MVC における Controller に相当します。
User Interface レイヤ、Domain レイヤ、Infrastructure レイヤを操作します。アプリケーションとしての中核を担います。
例えば、図書館の貸出管理システムにおいて、貸出画面で複数の図書を一度に貸出できるようにするのか、一冊ずつしか貸出できないようにするのかは、アプリケーションの仕様に左右されます。一度に複数の貸出ができるようにするために、図書オブジェクトを一時的にコレクションに格納するといったロジックは、「アプリケーションとしての動作」のためのものです。(*1)
[ Domain レイヤ ]
ドメインモデルに関するレイヤです。MVC における Model に相当します。
例えば、貸出期限や貸出可能図書数などは図書館の貸出規定として決められていることです。貸出可能図書数を超えてしまう場合は貸出できないようにするといったロジックは、アプリケーションとしての動作とは無関係なドメインロジック (ビジネスロジック) です。
[ Infrastructure レイヤ ]
データアクセスや周辺機器の制御などに関するレイヤです。
DDD において、業務エラーを表すのは Application レイヤだと僕は考えます。なぜなら、業務エラーは「アプリケーションとしての動作」の一種だからです。
Application レイヤが色々な手段で「業務エラーにすべき状況の検出」をし、「業務エラーとしてユーザーに通知」するわけです。
業務エラーにすべき状況を検出する手段には、例えば次のような手段があります。(*2)
- UI への入力値の妥当性を検証しその結果を見て検出
- ドメインモデルの検証用メソッドを実行した結果を見て検出
- ドメインモデルの状態プロパティを見て検出
- ドメインモデルのメソッドを実行した結果例外が発生するかを見て検出
- データアクセス時に例外が発生するかを見て検出
繰り返しますが、これらはあくまでも検出するための手段 (*3) であり、業務エラーそのものではありません。
業務エラーの検出後は、User Interface レイヤに対して、業務エラーをユーザーに通知するように指示を出します。この指示はメソッド呼び出し等 (*4) で行います。
ということで、最初に書きました通り、例外も戻り値も業務エラーを表すために使うものではないと主張します。特に、「本来例外を投げるべきところ」で「業務エラーは戻り値で表さなきゃいけないから戻り値にする」という考えには賛同できません。
今回、例として DDD を選びましたが、必ず DDD を適用しろとか、必ず Application レイヤで業務エラーを表せと言っているわけではありませんので注意してください。
また、ここで僕が述べたことは、他の設計思想においても当てはまるかもしれませんし、当てはまらないかもしれません。
*1:これは場合によっては Domain レイヤで行った方が適切だったりするかもですが、良い例が浮かばなかったので…
*2:これは決してボトムアップ設計を示唆しているわけではありません。
*3:"検出" を "選定" と置き換えた方が伝わりやすいかもしれませんね。
*4:まだ触ったことないですが ASP.NET MVC なら ViewData プロパティを使用するんですかね。
NoOptimization.cs
using System;
using System.Diagnostics;
static class Program
{
static void Main(string[] args)
{
TailCall(5);
Console.ReadLine();
}
static int TailCall(int i)
{
int frameCount = new StackTrace().FrameCount;
Console.WriteLine("FrameCount:{0}", frameCount);
if (i == 0)
{
return i;
}
i--;
return TailCall(i);
}
}
これと等価な IL コード。
NoOptimization.il
.assembly extern mscorlib { }
.assembly NoOptimization { }
.class private abstract auto ansi sealed beforefieldinit Program
extends [mscorlib]System.Object
{
.method private hidebysig static void Main(string[] args) cil managed
{
.entrypoint
.maxstack 8
ldc.i4.5
call int32 Program::TailCall(int32)
pop
call string [mscorlib]System.Console::ReadLine()
pop
ret
}
.method private hidebysig static int32 TailCall(int32 i) cil managed
{
.maxstack 2
.locals init (
[0] int32 frameCount)
newobj instance void [mscorlib]System.Diagnostics.StackTrace::.ctor()
callvirt instance int32 [mscorlib]System.Diagnostics.StackTrace::get_FrameCount()
stloc.0
ldstr "FrameCount:{0}"
ldloc.0
box int32
call void [mscorlib]System.Console::WriteLine(string, object)
ldarg.0
brtrue.s Label1
ldarg.0
ret
Label1:
ldarg.0
ldc.i4.1
sub
starg.s i
ldarg.0
call int32 Program::TailCall(int32)
ret
}
}
実行結果はこちら。末尾最適化が行われていないことがわかる。
FrameCount:2
FrameCount:3
FrameCount:4
FrameCount:5
FrameCount:6
FrameCount:7
続いて、IL コードに tail. プレフィックスを追加してみる。
追加する場所は、TailCall メソッドの下から 3 行目。
Optimization.il
.assembly extern mscorlib { }
.assembly Optimization { }
.class private abstract auto ansi sealed beforefieldinit Program
extends [mscorlib]System.Object
{
.method private hidebysig static void Main(string[] args) cil managed
{
.entrypoint
.maxstack 8
ldc.i4.5
call int32 Program::TailCall(int32)
pop
call string [mscorlib]System.Console::ReadLine()
pop
ret
}
.method private hidebysig static int32 TailCall(int32 i) cil managed
{
.maxstack 2
.locals init (
[0] int32 frameCount)
newobj instance void [mscorlib]System.Diagnostics.StackTrace::.ctor()
callvirt instance int32 [mscorlib]System.Diagnostics.StackTrace::get_FrameCount()
stloc.0
ldstr "FrameCount:{0}"
ldloc.0
box int32
call void [mscorlib]System.Console::WriteLine(string, object)
ldarg.0
brtrue.s Label1
ldarg.0
ret
Label1:
ldarg.0
ldc.i4.1
sub
starg.s i
ldarg.0
tail.
call int32 Program::TailCall(int32)
ret
}
}
実行結果はこちら。末尾最適化が行われていることがわかる。
FrameCount:2
FrameCount:2
FrameCount:2
FrameCount:2
FrameCount:2
FrameCount:2
さてここで、Optimization.il の TailCall メソッドに noinlining を付加して試してみる。
NoInlining.il
.assembly extern mscorlib { }
.assembly NoInlining { }
.class private abstract auto ansi sealed beforefieldinit Program
extends [mscorlib]System.Object
{
.method private hidebysig static void Main(string[] args) cil managed
{
.entrypoint
.maxstack 8
ldc.i4.5
call int32 Program::TailCall(int32)
pop
call string [mscorlib]System.Console::ReadLine()
pop
ret
}
.method private hidebysig static int32 TailCall(int32 i) cil managed noinlining
{
.maxstack 2
.locals init (
[0] int32 frameCount)
newobj instance void [mscorlib]System.Diagnostics.StackTrace::.ctor()
callvirt instance int32 [mscorlib]System.Diagnostics.StackTrace::get_FrameCount()
stloc.0
ldstr "FrameCount:{0}"
ldloc.0
box int32
call void [mscorlib]System.Console::WriteLine(string, object)
ldarg.0
brtrue.s Label1
ldarg.0
ret
Label1:
ldarg.0
ldc.i4.1
sub
starg.s i
ldarg.0
tail.
call int32 Program::TailCall(int32)
ret
}
}
実行結果はこちら。noinlining を付加しても末尾最適化が行われていることが確認できる。
FrameCount:2
FrameCount:2
FrameCount:2
FrameCount:2
FrameCount:2
FrameCount:2
では、Optimization.il の TailCall メソッドに reqsecobj を付加して試してみる。
ReqSecObj.il
.assembly extern mscorlib { }
.assembly ReqSecObj { }
.class private abstract auto ansi sealed beforefieldinit Program
extends [mscorlib]System.Object
{
.method private hidebysig static void Main(string[] args) cil managed
{
.entrypoint
.maxstack 8
ldc.i4.5
call int32 Program::TailCall(int32)
pop
call string [mscorlib]System.Console::ReadLine()
pop
ret
}
.method private hidebysig static reqsecobj int32 TailCall(int32 i) cil managed
{
.maxstack 2
.locals init (
[0] int32 frameCount)
newobj instance void [mscorlib]System.Diagnostics.StackTrace::.ctor()
callvirt instance int32 [mscorlib]System.Diagnostics.StackTrace::get_FrameCount()
stloc.0
ldstr "FrameCount:{0}"
ldloc.0
box int32
call void [mscorlib]System.Console::WriteLine(string, object)
ldarg.0
brtrue.s Label1
ldarg.0
ret
Label1:
ldarg.0
ldc.i4.1
sub
starg.s i
ldarg.0
tail.
call int32 Program::TailCall(int32)
ret
}
}
実行結果はこちら。noinlining とは異なり、reqsecobj を付加すると末尾最適化が行われなくなることが確認できる。
FrameCount:2
FrameCount:3
FrameCount:4
FrameCount:5
FrameCount:6
FrameCount:7
[関連記事]
JIT 最適化にも負けずに呼び出し元のメソッドを取得する方法
例えば、次のコードの実行結果を Debug ビルドと Release ビルド (非デバッグ実行) とで比較すると一目瞭然だ。
using System;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
namespace ConsoleApplication1
{
class Program
{
static void Main(string[] args)
{
Hoge();
Console.ReadLine();
}
static void Hoge()
{
Fuga();
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void Fuga()
{
const int callerFrameIndex = 1;
StackFrame callerFrame = new StackFrame(callerFrameIndex);
MethodBase callerMethod = callerFrame.GetMethod();
Console.WriteLine(callerMethod.Name);
}
}
}
Debug ビルドでは Hoge と出力されるが、Release ビルド (非デバッグ実行) では Main と出力されることが確認できる。つまり、Release ビルド (非デバッグ実行) では JIT 最適化により Hoge メソッドがインライン展開されているわけである。
JIT 最適化によるインライン展開を抑止する方法としては、MethodImpl 属性 を付加して MethodImplOptions.NoInlining を指定するという方法が提供されている。しかし、この方法でインライン展開が抑止されるのは属性が付加されたメソッドのみである。そのため、呼び出し元のメソッド (上記の例なら Hoge メソッド) にこの属性を付加しなければならない。
また、この属性は絶対的なものではなく、64 bit CLR では無視されてしまうらしい。
また、インライン展開の抑止は、C# ・ IL レベルのメソッド呼び出しとコールスタックの一致を保証するわけではない。64 bit - CLR では、インライン展開以外に末尾最適化によっても、メソッド呼び出しとコールスタックの不一致が生じる場合があるのだが、NoInlining では末尾最適化は抑止されない。
デバッグ技 : .ini ファイルによる JIT コンパイラ制御
# このリンク先では ini ファイルを使用して インライン展開を抑止する方法 コールスタックの不一致 を防ぐ方法が紹介されているが、これはあくまでもデバッグ目的の手段である。
では、呼び出し元メソッドのコールスタックを維持する方法は無いだろうか。
実はある。
IL レベルで reqsecobj キーワードが付加されているメソッドでは、呼び出し元メソッドのコールスタックが維持されるのである。 (reqsecobj キーワードが付加されているメソッド自身のコールスタックも維持される。)
C# コンパイラには、このキーワードをメソッドに付加する方法が用意されていて、メソッドに DynamicSecurityMethodAttribute クラス (System.Security) を属性として付加すれば良い。
このクラスは mscorlib.dll の internal クラスであり僕らは本来使用することができないのだが、同名のクラスを自前で用意して使用すれば C# コンパイラは reqsecobj キーワードを付加してくれる。
先ほどのコードに、DynamicSecurity 属性を付けて、再び Release ビルド (非デバッグ実行) で実行してみると、見事にコールスタックが維持されることが確認できる。
using System;
using System.Diagnostics;
using System.Reflection;
using System.Security;
namespace ConsoleApplication1
{
class Program
{
static void Main(string[] args)
{
Hoge();
Console.ReadLine();
}
static void Hoge()
{
Fuga();
}
[DynamicSecurityMethod]
static void Fuga()
{
const int callerFrameIndex = 1;
StackFrame callerFrame = new StackFrame(callerFrameIndex);
MethodBase callerMethod = callerFrame.GetMethod();
Console.WriteLine(callerMethod.Name);
}
}
}
namespace System.Security
{
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true, Inherited = false)]
internal sealed class DynamicSecurityMethodAttribute : Attribute
{
}
}
見ての通り、呼び出し元メソッドである Hoge メソッドには属性の付加が一切不要である。
[修正履歴]
2008/10/07
NyaRuRu さんのコメントを元に記事本文を修正。
重要な削除箇所は取り消し線 + 文字色変更 (灰色)。
重要な追記箇所は背景色変更 (緑色)。
目印は付けていないが、何箇所か「インライン展開が抑止」という旨の記述を「コールスタックが維持」という記述に修正してある。
2008/10/08
冒頭の検証用コードで、Fuga メソッドまでインライン展開されてしまいエラーとなっていたので 、Fuga メソッドに MethodImpl(MethodImplOptions.NoInlining) 属性を付加。