コンストラクターが例外をスローするのはどのような場合に適切でしょうか?
-
09-06-2019 - |
質問
コンストラクターが例外をスローするのはどのような場合に適切でしょうか?(または目標 C の場合:init'er が nil を返すのが正しいのはいつですか?)
オブジェクトが完了していない場合、コンストラクターは失敗し、したがってオブジェクトの作成を拒否する必要があるように思えます。つまり、コンストラクターは、メソッドを意味のある方法で呼び出すことができる機能的で動作するオブジェクトを提供するために、呼び出し元と契約を結ぶ必要がありますか?それは合理的ですか?
解決
コンストラクターの仕事は、オブジェクトを使用可能な状態にすることです。これについては基本的に 2 つの考え方があります。
1 つのグループは 2 段階の建設を支持しています。コンストラクターは単にオブジェクトを、いかなる作業も拒否するスリーパー状態にするだけです。実際の初期化を行う追加関数があります。
このアプローチの背後にある理由がまったく理解できませんでした。私は、オブジェクトが完全に初期化され、構築後に使用できる 1 段階構築をサポートするグループにしっかりと属しています。
1 段階コンストラクターは、オブジェクトの完全な初期化に失敗した場合にスローする必要があります。オブジェクトを初期化できない場合は、オブジェクトの存在を許可してはいけないため、コンストラクターはスローする必要があります。
他のヒント
エリック・リッパート 言う 4種類の例外があります。
- 致命的な例外はあなたのせいではなく、それを防ぐことも、賢明にそれを取り除くこともできません。
- 骨の折れる例外はあなた自身の責任であり、あなたはそれを防ぐことができたはずであり、したがってそれはあなたのコードのバグです。
- 厄介な例外は、不幸な設計上の決定の結果です。厄介な例外は、まったく例外的ではない状況でスローされるため、常にキャッチして処理する必要があります。
- そして最後に、外生例外は、不運な設計選択の結果ではないことを除けば、やや厄介な例外に似ているように見えます。むしろ、それらは、乱雑な外部現実が美しく鮮明なプログラム ロジックに影響を与えた結果です。
コンストラクターはそれ自体で致命的な例外をスローしてはなりませんが、コンストラクターが実行するコードによって致命的な例外が発生する可能性があります。「メモリ不足」のようなことは制御できるものではありませんが、コンストラクターで発生した場合は、それが発生します。
ボーンヘッド例外はコード内で決して発生すべきではないため、まさに例外です。
厄介な例外 (例は次のとおりです) Int32.Parse()
) コンストラクターには例外ではない状況がないため、コンストラクターによってスローされるべきではありません。
最後に、外部例外は避ける必要がありますが、外部環境 (ネットワークやファイルシステムなど) に依存する何かをコンストラクターで実行している場合は、例外をスローすることが適切です。
がある 一般的に オブジェクトの初期化を構築から切り離しても何も得られません。RAII は正しいです。コンストラクターの呼び出しが成功すると、完全に初期化されたライブ オブジェクトが生成されるか、失敗するはずです。 全て コード パスのどの時点でも失敗すると、常に例外がスローされる必要があります。別の init() メソッドを使用しても、あるレベルで複雑さが増すこと以外は何も得られません。ctor コントラクトは、機能的に有効なオブジェクトを返すか、自身の後でクリーンアップしてスローするかのいずれかである必要があります。
別の init メソッドを実装する場合を考えてみましょう。 まだ それを呼ばなければなりません。例外をスローする可能性は依然としてあり、例外は処理する必要があり、事実上常にコンストラクターの直後に呼び出す必要がありますが、オブジェクトの状態が 2 つではなく 4 つ (IE、構築済み、初期化済み、初期化されていない、失敗したものと、単に有効で存在しないもの)。
いずれにせよ、私が 25 年間のオブジェクト指向開発で遭遇した、別個の init メソッドが「何らかの問題を解決する」ように見えるケースは、設計上の欠陥です。オブジェクトが今必要ない場合は、今すぐ構築すべきではありません。今必要な場合は、初期化する必要があります。KISS は常に従うべき原則であり、インターフェイスの動作、状態、API はオブジェクトがどのように行うかではなく、オブジェクトの動作を反映する必要があるという単純な概念とともに、オブジェクトがどのような種類のものであるかをクライアント コードで認識すべきではありません。初期化が必要な内部状態であるため、init after パターンはこの原則に違反します。
クラスが部分的に作成されるとさまざまな問題が発生する可能性があるため、私は決して作成しないと言います。
構築中に何かを検証する必要がある場合は、コンストラクターをプライベートにして、パブリックな静的ファクトリー メソッドを定義します。このメソッドは、何かが無効な場合にスローできます。ただし、すべてがチェックアウトされた場合は、スローしないことが保証されているコンストラクターを呼び出します。
コンストラクターは、オブジェクトの構築を完了できない場合に例外をスローする必要があります。
たとえば、コンストラクターが 1024 KB の RAM を割り当てることになっているが、それが失敗した場合、例外をスローする必要があります。これにより、コンストラクターの呼び出し元は、オブジェクトが使用できる状態ではなく、エラーが発生したことを認識します。どこかを修正する必要があります。
半分初期化され半分死んでいるオブジェクトは、呼び出し元にはそれを知る方法がないため、単に問題や問題を引き起こすだけです。true または false を返す isOK() 関数の呼び出しを実行するプログラミングに依存するよりも、問題が発生したときにコンストラクターがエラーをスローするようにしたいと考えています。
特にコンストラクター内でリソースを割り当てている場合は、常にかなり危険です。言語によってはデストラクターが呼び出されないため、手動でクリーンアップする必要があります。それは、言語でオブジェクトの存続期間がいつ始まるかによって異なります。
私が実際にそれを行ったのは、どこかにセキュリティ上の問題があり、オブジェクトを作成できないというよりも、作成すべきでない場合だけです。
コンストラクターが適切にクリーンアップする限り、コンストラクターが例外をスローするのは合理的です。フォローすると、 ライ パラダイム (リソースの取得は初期化である) は コンストラクターが意味のある作業を行うのは非常に一般的です。適切に作成されたコンストラクターは、完全に初期化できない場合、コンストラクター自体の後でクリーンアップします。
私の知る限り、1 段階構造と 2 段階構造の両方の長所を体現する明確な解決策を提示している人は誰もいません。
注記: この回答は C# を前提としていますが、原則はほとんどの言語に適用できます。
まず、両方の利点は次のとおりです。
ワンステージ
1 段階の構築は、オブジェクトが無効な状態で存在することを防ぎ、あらゆる種類の誤った状態管理とそれに伴うすべてのバグを防ぐという利点があります。ただし、コンストラクターに例外をスローさせたくないため、奇妙に感じる人もいます。初期化引数が無効な場合は例外をスローする必要がある場合もあります。
public class Person
{
public string Name { get; }
public DateTime DateOfBirth { get; }
public Person(string name, DateTime dateOfBirth)
{
if (string.IsNullOrWhitespace(name))
{
throw new ArgumentException(nameof(name));
}
if (dateOfBirth > DateTime.UtcNow) // side note: bad use of DateTime.UtcNow
{
throw new ArgumentOutOfRangeException(nameof(dateOfBirth));
}
this.Name = name;
this.DateOfBirth = dateOfBirth;
}
}
2 段階の検証方法
2 段階の構築では、検証をコンストラクターの外部で実行できるため、コンストラクター内で例外をスローする必要がなくなります。ただし、「無効な」インスタンスが残ることになります。これは、インスタンスを追跡および管理する必要がある状態が存在するか、ヒープ割り当ての直後にインスタンスを破棄することを意味します。そこで次のような疑問が生じます。結局使用することさえないオブジェクトに対してヒープ割り当て、つまりメモリ収集を実行するのはなぜでしょうか?
public class Person
{
public string Name { get; }
public DateTime DateOfBirth { get; }
public Person(string name, DateTime dateOfBirth)
{
this.Name = name;
this.DateOfBirth = dateOfBirth;
}
public void Validate()
{
if (string.IsNullOrWhitespace(Name))
{
throw new ArgumentException(nameof(Name));
}
if (DateOfBirth > DateTime.UtcNow) // side note: bad use of DateTime.UtcNow
{
throw new ArgumentOutOfRangeException(nameof(DateOfBirth));
}
}
}
プライベートコンストラクターを介したシングルステージ
では、コンストラクターから例外を排除し、すぐに破棄されるオブジェクトに対するヒープ割り当てを実行しないようにするにはどうすればよいでしょうか?かなり基本的なことです:コンストラクターをプライベートにし、インスタンス化、つまりヒープ割り当てのみを実行するように指定された静的メソッドを介してインスタンスを作成します。 後 検証。
public class Person
{
public string Name { get; }
public DateTime DateOfBirth { get; }
private Person(string name, DateTime dateOfBirth)
{
this.Name = name;
this.DateOfBirth = dateOfBirth;
}
public static Person Create(
string name,
DateTime dateOfBirth)
{
if (string.IsNullOrWhitespace(Name))
{
throw new ArgumentException(nameof(name));
}
if (dateOfBirth > DateTime.UtcNow) // side note: bad use of DateTime.UtcNow
{
throw new ArgumentOutOfRangeException(nameof(DateOfBirth));
}
return new Person(name, dateOfBirth);
}
}
プライベートコンストラクターを介した非同期シングルステージ
前述の検証とヒープ割り当て防止の利点とは別に、以前の方法論にはもう 1 つの気の利いた利点があります。非同期のサポート。これは、API を使用する前にベアラー トークンを取得する必要がある場合など、多段階認証を扱う場合に便利です。こうすることで、API クライアントが無効な「サインアウト」状態になることはなく、リクエストの実行中に認証エラーが発生した場合でも API クライアントを簡単に再作成できます。
public class RestApiClient
{
public RestApiClient(HttpClient httpClient)
{
this.httpClient = new httpClient;
}
public async Task<RestApiClient> Create(string username, string password)
{
if (username == null)
{
throw new ArgumentNullException(nameof(username));
}
if (password == null)
{
throw new ArgumentNullException(nameof(password));
}
var basicAuthBytes = Encoding.ASCII.GetBytes($"{username}:{password}");
var basicAuthValue = Convert.ToBase64String(basicAuthBytes);
var authenticationHttpClient = new HttpClient
{
BaseUri = new Uri("https://auth.example.io"),
DefaultRequestHeaders = {
Authentication = new AuthenticationHeaderValue("Basic", basicAuthValue)
}
};
using (authenticationHttpClient)
{
var response = await httpClient.GetAsync("login");
var content = response.Content.ReadAsStringAsync();
var authToken = content;
var restApiHttpClient = new HttpClient
{
BaseUri = new Uri("https://api.example.io"), // notice this differs from the auth uri
DefaultRequestHeaders = {
Authentication = new AuthenticationHeaderValue("Bearer", authToken)
}
};
return new RestApiClient(restApiHttpClient);
}
}
}
私の経験では、この方法の欠点はほとんどありません。
一般に、この方法を使用すると、そのクラスを DTO として使用できなくなることを意味します。これは、パブリックなデフォルト コンストラクターを使用しないオブジェクトへの逆シリアル化は、せいぜい難しいためです。ただし、オブジェクトを DTO として使用している場合は、実際にはオブジェクト自体を検証するのではなく、オブジェクトの値を使用しようとするときにその値を無効にする必要があります。これは、技術的には値が「無効」ではないためです。 DTOに。
また、IOC コンテナにオブジェクトの作成を許可する必要がある場合、最終的にファクトリ メソッドまたはクラスを作成することになります。そうしないと、コンテナがオブジェクトのインスタンス化方法を認識できないためです。ただし、多くの場合、ファクトリ メソッドは次のいずれかになります。 Create
メソッド自体。
UI コントロール (ASPX、WinForms、WPF など) を作成している場合は、コントロールの作成時にデザイナー (Visual Studio) が例外を処理できないため、コンストラクターで例外をスローしないようにする必要があります。コントロールのライフサイクル (コントロール イベント) を理解し、可能な限り遅延初期化を使用します。
イニシャライザで例外をスローすると、コードが [[[MyObj alloc] init] autorelease]
例外が自動解放をスキップするためです。
この質問を参照してください:
有効なオブジェクトを作成できない場合は、コンストラクターから例外をスローする必要があります。これにより、クラス内に適切な不変式を提供できるようになります。
実際には、非常に注意が必要な場合があります。C++ ではデストラクターは呼び出されないので、リソースを割り当てた後にスローする場合は、それを適切に処理するために十分な注意が必要であることに注意してください。
このページ C++ の状況について徹底的に議論しています。
コンストラクターでオブジェクトを初期化できない場合は、例外をスローします。一例として、引数が不正であることが挙げられます。
一般的な経験則として、問題の原因が何かが間違っていることを通知するメソッドに近い場合、デバッグが容易になるため、例外は常にできるだけ早くスローされる必要があります。
構築中に例外をスローすることは、コードをより複雑にするための優れた方法です。簡単そうに見えることが突然難しくなります。たとえば、スタックがあるとします。スタックをポップして最上位の値を返すにはどうすればよいでしょうか?まあ、スタック内のオブジェクトがコンストラクターをスローできる場合 (呼び出し元に返す一時オブジェクトを構築する)、データが失われないという保証はありません (スタック ポインターをデクリメントし、値のコピー コンストラクターを使用して戻り値を構築します)。 stack、これはスローします、そしてアイテムを失ったばかりのスタックができました)!これが、 std::stack::pop が値を返さず、 std::stack::top を呼び出す必要がある理由です。
この問題はよく説明されています ここ, 、項目 10、例外安全なコードの作成を確認してください。
オブジェクト指向の通常の規約では、オブジェクト メソッドは実際に機能します。
したがって、必然的に、コンストラクター/初期化からゾンビオブジェクトを決して返さないようにします。
ゾンビは機能せず、内部コンポーネントが欠落している可能性があります。発生を待っている null ポインタ例外だけです。
私が初めてゾンビを作成したのは何年も前です。Objective C でした。
すべての経験則と同様に、「例外」があります。
という可能性は十分にあります。 特定のインターフェース 例外を絞ることが許可されている「初期化」のメソッドが存在することを示す契約があるかもしれません。このインターフェイスを実装するオブジェクトは、initialize が呼び出されるまで、プロパティ セッターを除く呼び出しに正しく応答しない可能性があります。ブート プロセス中に OO オペレーティング システムのデバイス ドライバーにこれを使用しましたが、正常に動作しました。
一般に、ゾンビ オブジェクトは必要ありません。Smalltalk のような言語では、 なる 物事は少しめまぐるしくなりますが、 なる スタイルも悪いです。 become では、オブジェクトをその場で別のオブジェクトに変更できるため、エンベロープ ラッパー (高度な C++) やストラテジ パターン (GOF) は必要ありません。
Objective-C のベスト プラクティスについては説明できませんが、C++ ではコンストラクターが例外をスローしても問題ありません。特に、isOK() メソッドを呼び出す以外に、構築時に発生した例外条件を確実に報告する方法は他にないためです。
関数 try ブロック機能は、コンストラクターのメンバーごとの初期化の失敗をサポートするために特別に設計されました (ただし、通常の関数にも使用できます)。これが、スローされる例外情報を変更または強化する唯一の方法です。しかし、その本来の設計目的 (コンストラクターでの使用) のため、例外を空の catch() 節で飲み込むことは許可されていません。
はい、コンストラクターがその内部部分の 1 つを構築できなかった場合、そのコンストラクターが - 選択により - コンストラクターの責任で、 明示的な例外 、コンストラクターのドキュメントに正式に記載されています。
これが唯一の選択肢ではありません。コンストラクターを終了してオブジェクトを構築することもできますが、一貫性のない状態を通知できるようにするために、メソッド 'isCoherent()' が false を返します (場合によっては、例外による実行ワークフロー)
警告:EricSchaefer がコメントで述べたように、単体テストが複雑になる可能性があります (スローにより、 循環的複雑さ トリガーとなる条件による関数の機能)
呼び出し元が原因で失敗した場合 (呼び出し元によって指定された null 引数など、呼び出されたコンストラクターが null 以外の引数を予期している場合)、コンストラクターはいずれにしても未チェックの実行時例外をスローします。
言語に完全に依存しない答えが得られるかどうかはわかりません。一部の言語では、例外とメモリ管理の処理が異なります。
私は以前、例外を決して使用せず、イニシャライザでエラー コードのみを使用することを要求するコーディング標準に基づいて作業したことがあります。これは、例外の処理が不十分な言語によって開発者が火傷を負っていたためです。ガベージ コレクションのない言語では、ヒープとスタックの処理方法が大きく異なります。これは、非 RAII オブジェクトにとって重要になる可能性があります。ただし、コンストラクターの後にイニシャライザーを呼び出す必要があるかどうかをデフォルトで認識できるように、チームが一貫性を保つことを決定することが重要です。すべてのメソッド (コンストラクターを含む) は、呼び出し元が例外の処理方法を理解できるように、どのような例外をスローできるかについても十分に文書化する必要があります。
オブジェクトの初期化を忘れやすいため、私は一般的に 1 段階の構築を支持していますが、これには例外もたくさんあります。
- 例外に対する言語サポートはあまり良くありません。
- まだ使用する差し迫ったデザイン上の理由がある
new
そしてdelete
- 初期化はプロセッサを集中的に使用するため、オブジェクトを作成したスレッドと非同期で実行する必要があります。
- 別の言語を使用するアプリケーションへのインターフェイスの外側で例外をスローする可能性のある DLL を作成しています。この場合、例外をスローしないことはそれほど問題ではなく、パブリック インターフェイスの前で例外が確実にキャッチされるようにすることが重要です。(C# で C++ 例外をキャッチすることはできますが、飛び越えなければならないフープがあります。)
- 静的コンストラクター (C#)
OPの質問には「言語に依存しない」タグが付いています...この質問は、すべての言語/状況に対して同じように安全に答えることができません。
次の C# の例のクラス階層は、クラス B のコンストラクターをスローし、クラス A の即時呼び出しをスキップします。 IDisposeable.Dispose
メインの出口から出ると using
, 、クラス A のリソースの明示的な破棄をスキップします。
たとえば、クラス A が Socket
建設中、ネットワーク リソースに接続されているため、建設後も同様のことが起こる可能性があります。 using
ブロック (比較的隠れた異常)。
class A : IDisposable
{
public A()
{
Console.WriteLine("Initialize A's resources.");
}
public void Dispose()
{
Console.WriteLine("Dispose A's resources.");
}
}
class B : A, IDisposable
{
public B()
{
Console.WriteLine("Initialize B's resources.");
throw new Exception("B construction failure: B can cleanup anything before throwing so this is not a worry.");
}
public new void Dispose()
{
Console.WriteLine("Dispose B's resources.");
base.Dispose();
}
}
class C : B, IDisposable
{
public C()
{
Console.WriteLine("Initialize C's resources. Not called because B throws during construction. C's resources not a worry.");
}
public new void Dispose()
{
Console.WriteLine("Dispose C's resources.");
base.Dispose();
}
}
class Program
{
static void Main(string[] args)
{
try
{
using (C c = new C())
{
}
}
catch
{
}
// Resource's allocated by c's "A" not explicitly disposed.
}
}
厳密に Java の観点から言えば、コンストラクターを不正な値で初期化するときは必ず例外をスローする必要があります。そうすれば、悪い状態で構築されることはありません。
私にとって、これはやや哲学的な設計上の決定です。
ctor 時以降、存在する限り有効なインスタンスがあるのは非常に便利です。多くの重要なケースでは、メモリ/リソースの割り当てができない場合に ctor から例外をスローする必要がある場合があります。
他のアプローチとしては、init() メソッドがありますが、これには独自の問題がいくつかあります。その 1 つは、init() が実際に呼び出されるようにすることです。
バリアントでは、アクセサー/ミューテーターが初めて呼び出されたときに自動的に init() を呼び出す遅延アプローチが使用されていますが、そのためには潜在的な呼び出し元がオブジェクトが有効であるかどうかを考慮する必要があります。(「それは存在するので、それは有効な哲学である」とは対照的です)。
この問題に対処するために提案されたさまざまな設計パターンも見てきました。たとえば、ctor を介して初期オブジェクトを作成できますが、アクセサー/ミューテーターを使用して、含まれている初期化されたオブジェクトを取得するには init() を呼び出す必要があります。
それぞれのアプローチには一長一短があります。私はこれらすべてをうまく使用しました。作成した瞬間からすぐに使えるオブジェクトを作成しない場合は、init() の前にユーザーが操作しないように、大量のアサートまたは例外を使用することをお勧めします。
補遺
私は C++ プログラマーの観点から書きました。また、例外がスローされたときに解放されるリソースを処理するために RAII イディオムを適切に使用していることも前提としています。
私は Objective C を勉強しているところなので、経験に基づいて話すことはできませんが、これについては Apple のドキュメントで読みました。
あなたが尋ねた質問に対処する方法を教えてくれるだけでなく、それをうまく説明します。
すべてのオブジェクト作成にファクトリまたはファクトリ メソッドを使用すると、コンストラクタから例外をスローせずに無効なオブジェクトを回避できます。作成メソッドは、要求されたオブジェクトを作成できる場合はそれを返し、作成できない場合は null を返す必要があります。null を返してもオブジェクト作成時に何が問題だったのかがわからないため、クラスのユーザーの構築エラーを処理する際の柔軟性が少し失われます。ただし、オブジェクトをリクエストするたびに複数の例外ハンドラーが複雑になることや、処理すべきではない例外をキャッチするリスクも回避できます。
私がこれまでに見た例外に関する最良のアドバイスは、事後条件を満たさない、または不変条件を維持できない場合に限り、例外をスローすることです。
そのアドバイスは、不明確な主観的な決定を置き換えるものです(それは 良いアイデア) すでに行っているはずの設計上の決定 (不変および事後条件) に基づいた、技術的で正確な質問。
コンストラクターは、そのアドバイスの特殊なケースにすぎませんが、特殊なケースではありません。そこで問題は、クラスにはどのような不変条件が必要かということになります。構築後に呼び出される個別の初期化メソッドの支持者は、クラスに 2 つ以上の初期化メソッドがあることを示唆しています。 動作モード, 、 準備ができていない 構築後のモードと少なくとも 1 つの 準備ができて 初期化後に入るモード。これはさらに複雑な問題ですが、クラスに複数の動作モードがある場合には許容できます。クラスに動作モードがない場合、その複雑化にどのような価値があるのかを理解するのは困難です。
セットアップを別の初期化メソッドにプッシュしても、例外のスローを回避できるわけではないことに注意してください。コンストラクターがスローした可能性のある例外は、初期化メソッドによってスローされるようになります。クラスの便利なメソッドはすべて、初期化されていないオブジェクトに対して呼び出された場合に例外をスローする必要があります。
コンストラクターによって例外がスローされる可能性を回避するのは面倒であることにも注意してください。 不可能 多くの標準ライブラリに含まれています。これは、これらのライブラリの設計者が、コンストラクターから例外をスローすることが良い考えであると信じているためです。特に、共有不可能または有限のリソースを取得しようとする操作 (メモリの割り当てなど) は失敗する可能性があり、その失敗は通常、OO 言語およびライブラリで例外をスローすることによって示されます。
ctor は「賢い」ことを行うことを想定していないため、とにかく例外をスローする必要はありません。より複雑なオブジェクトのセットアップを実行する場合は、Init() または Setup() メソッドを使用します。