【Hackを掌握する極限の知見】`throws`アノテーションの設計と運用:例外を型システムに服従させる方法
コードレビューの場で、次のようなコードを見かけて絶望したことはないだろうか。
// 悪夢のようなプロダクションコードの残骸
<<__EntryPoint>>
async function main_async(): Awaitable
$user = await $userService->fetchUser(42);
// この呼び出しがどんな例外を投げるのか、IDEは教えてくれない
// ドキュメントを読むしかない? 冗談じゃない、コードは嘘をつかないが例外は隠れる
}
PHPの系譜を引くHack言語は、その歴史的背景からかつて「動的型の自由度」と「PHP互換性」の狭間で揺らいでいた。しかし、現在のHHVM(HipHop Virtual Machine)と高度に洗練されたHackの型チェッカー(`hh_client`)が目指す到達点は明確だ。それは「実行時エラーの完全な駆逐」である。
今回は、Hackの静的型システムの真骨頂であり、例外処理をコンパイル時の型安全性の統制下に置くための最終兵器――`throws`アノテーションの設計と運用について、HHVMの内部挙動を踏まえながら徹底的に解説する。
—
なぜ「野良例外(Unchecked Exception)」はバグの温床なのか?
Javaなどの言語で疲弊した開発者なら身に覚えがあるはずだ。すべての例外をチェック例外(Checked Exception)にすることの冗長性は、確かに開発スピードを殺した。そのため、近年のモダン言語(RustやGo、あるいはTypeScript)は、エラーを「値(Result型やUnion型)」として扱う方向へシフトしている。
しかし、Hack言語の選択はユニークかつ極めてプラグマティックだ。Hackは例外機構(`Exception`)を維持しながらも、`<<__NoThrow>>` や `throws` アノテーションを導入することで、例外を静的型チェッカーの監視下に置くことに成功した。
動的型付き言語の悪癖を引きずるな
Strictモード(`<<__strict__>>`)で記述されたコードベースにおいて、型定義されていない例外の伝播は、静的解析の完全な破壊を意味する。
「このサービス層のメソッドは、データベース接続断のときに何を投げるんだっけ?」とソースコードの奥底まで潜る必要が生じた時点で、そのアーキテクチャは破綻している。
例外は、関数シグネチャの一部でなければならない。
—
Hackにおける `throws` アノテーションの基本構文
Hackでは、関数やメソッドが特定の例外を投げる可能性がある場合、それを明示的に宣言する。これにより、呼び出し側はハンドリング(あるいは再スローの宣言)を強制される。
まずは、実務の現場でそのまま使える、堅牢なAPI連携コンポーネントの設計パターンを見てみよう。
<<__strict__>>
namespace HackExpert\ExceptionDesign;
/
- 外部API通信起因の基底例外
/
class ApiException extends \Exception {}
/
- レートリミット超え例外(リトライ可能)
/
class RateLimitExceededException extends ApiException {
public function __construct(public int $retryAfterSeconds) {
parent::__construct(“Rate limit exceeded. Retry after {$retryAfterSeconds}s.”);
}
}
/
- 致命的なAPIエラー例外(リトライ不可)
/
class FatalApiException extends ApiException {}
final class RemoteApiClient {
/
- ユーザーデータを取得する
- throws宣言により、このメソッドの呼び出し元は
- RateLimitExceededException と FatalApiException の処理を静的に強制される。
/
public async function fetchUserDataAsync(int $userId): Awaitable
throws RateLimitExceededException, FatalApiException {
$response = await $this->httpClient->getAsync(“/users/{$userId}”);
if ($response->getStatusCode() === 429) {
// 構造化された例外を投げる
throw new RateLimitExceededException(60);
}
if ($response->getStatusCode() >= 500) {
throw new FatalApiException(“Upstream server error: ” . (string)$response->getStatusCode());
}
// 正常系パース処理
return Shape::rowToShape($response->getBody());
}
}
このコードの美しさは、`throws` アノテーションがあることで、呼び出し側(Consumer)が「どのような異常系に備えるべきか」をコンパイラレベルで網羅できる点にある。
—
呼び出し側の義務:例外の網羅的ハンドリングと伝播
では、この `RemoteApiClient` を呼び出すコントローラーや上位レイヤーのコードはどうなるべきか。
「とりあえず `try-catch (\Exception $e)` で全キャッチする」という、最も愚劣で保守性を殺すアンチパターンを排除しよう。
<<__strict__>>
namespace HackExpert\ExceptionDesign;
final class UserSyncService {
public function __construct(private RemoteApiClient $client) {}
/
- 上位レイヤーへ例外をさらに伝播させる場合のシグネチャ
/
public async function syncUserAsync(int $userId): Awaitable
throws RateLimitExceededException {
try {
$data = await $this->client->fetchUserDataAsync($userId);
// 永続化処理…
} catch (FatalApiException $e) {
// 致命的エラーはログに記録してラップし、上位には流さない
Logger::error(“Fatal API error during sync: ” . $e->getMessage());
// 処理を安全に中断(例外を投げないルート)
return;
}
// 注意: RateLimitExceededException は catch されていないため、
// throws 宣言により、このメソッド自体も throws RateLimitExceededException を強制される。
}
}
もし `syncUserAsync` のシグネチャに `throws RateLimitExceededException` を書き忘れた場合、Hackの型チェッカー(`hh_client`)は容赦なくビルドを失敗させる。これが、静的型システムによる例外管理の真骨頂だ。
—
HHVMアーキテクチャの視点:`throws` がパフォーマンスに与える影響
ここでチーフアーキテクトとして、HHVMのエンジン内部の挙動についても言及しておこう。
「例外を型システムで厳密に管理すると、実行時オーバーヘッドが増えるのではないか?」という懸念を持つエンジニアは鋭いが、心配には及ばない。
1. JITコンパイルと例外テーブル(Exception Handling Tables)
HHVMのTC(Translation Cache)は、関数ごとの例外ハンドリング領域をあらかじめマシン語レベルで最適化して配置する。型チェッカーが事前に「どの関数がどの例外を投げる可能性があるか」を静的に確定させることで、HHVMのバイトコード検証(Verifying bytecode)のフェーズが劇的に効率化される。
2. 動的ディスパッチの排除
野良例外を許容する言語では、実行時まで「どの例外クラスが飛んでくるか」が不明なため、ポリモーフィックな例外キャッチのオーバーヘッドが生じやすい。Hackの厳格な `throws` 宣言は、オプティマイザに対して「このスコープで発生しうる例外の型階層の境界」を明確に伝え、不要な型ガード命令の生成を抑制する。
つまり、型安全性を高めるための `throws` アノテーションは、Runtimeの最適化にとっても強力なヒントとして機能しているのだ。
—
実務運用のアンチパターンとベストプラクティス
最後に、大規模プロダクションコードで `throws` を運用する際の鉄則を授けよう。
1. `\Exception` や `\Throwable` を `throws` するな
基底クラスである `\Exception` を `throws` 宣言するのは、「私は何も型設計をしていません」と宣言しているようなものだ。
ドメインごとにカスタム例外階層(例: `DomainException`, `InfrastructureException`)を必ず設計し、具体的な型を `throws` に指定せよ。
2. ライブラリ境界での例外の翻訳(Exception Translation)
インフラストラクチャ層(データベースや外部HTTPクライアント)の低レベル例外を、そのままドメイン層やUI層へ露出させてはならない。
リポジトリやAPIクライアントの境界で、フレームワーク固有の例外をキャッチし、ドメイン固有の `throws` 定義された例外へと必ずラップ(翻訳)すること。
// インフラストラクチャ層の具象クラス
final class SqlUserRepository implements UserRepositoryInterface {
public function findById(int $id): User throws UserNotFoundException {
try {
$row = $this->db->queryRow(“SELECT FROM users WHERE id = ?”, [$id]);
if ($row === null) {
throw new UserNotFoundException($id);
}
return new User($row);
} catch (\PDOException $e) {
// データベース固有の例外を、ドメイン例外に翻訳
throw new DatabaseAccessException(“Failed to query user”, 0, $e);
}
}
}
—
結びにかえて
コードレビューにおいて、「なぜこの例外はキャッチされないのか」「どこで握りつぶされているのか」という不毛な議論に時間を溶かすのはもう終わりにするべきだ。
Hackの静的型システムと `throws` アノテーションを使いこなせば、エラーハンドリングは「お祈りプログラミング」から「コンパイル時に保証された堅牢な契約(Design by Contract)」へと昇華する。
厳格さ(Strictness)を恐れるな。型チェッカーを味方につけたコードこそが、長期的な保守性と圧倒的な実行時パフォーマンスを両立する唯一の道である。