
ソフトウェアを書くことは、複雑さとシンプルさの間の戦いです。この2つのバランスを取ることは困難です。トレードオフは、長くメンテナンスできないメソッドと過度な抽象化の間にあります。どちらの方向に傾きすぎても、コードの可読性が損なわれ、欠陥の可能性が高まります。
欠陥は回避できるのでしょうか?NASAは挑戦していますが、彼らは大量のテストも行っています。彼らのソフトウェアは文字通りミッションクリティカルで、一発勝負です。ほとんどの組織にとって、これは当てはまらず、大量のテストはコストがかかり実用的ではありません。テストに代わるものはありませんが、テストなしでも欠陥に強いコードを書くことは可能です。
20年間のコーディングとアプリケーション設計の経験から、欠陥を減らす4つの実践方法を特定しました。最初の2つの実践は欠陥の導入を制限し、最後の2つの実践は欠陥を露呈させます。それぞれの実践は、多くの書籍が書かれている広大なトピックです。各実践を数段落に要約し、可能な限り追加情報へのリンクを提供しました。
1. シンプルなコードを書く
シンプルであることは簡単なはずですが、そうではありません。シンプルなコードを書くことは困難です。
これを読んで、シンプルな言語機能を使うことだと思う人もいるでしょうが、そうではありません。シンプルなコードは愚かなコードではありません。
客観性を保つため、循環的複雑度を測定基準として使用しています。複雑さを測定する他の方法や他のタイプの複雑さもあり、これらのトピックを後の記事で探求したいと思います。
Microsoftは循環的複雑度を次のように定義しています:
循環的複雑度は、メソッドを通る線形独立パスの数を測定します。これは条件分岐の数と複雑さによって決定されます。低い循環的複雑度は一般的に、理解、テスト、保守が容易なメソッドを示します。
低い循環的複雑度とは何でしょうか?Microsoftは循環的複雑度を25未満に保つことを推奨しています。
正直に言うと、Microsoftの循環的複雑度25の推奨は高すぎると感じています。保守性と複雑さの観点から、理想的なメソッドサイズは1〜10行で、循環的複雑度は1〜5の間だと思います。
Bill WagnerはEffective C#, Second Editionでメソッドサイズについて書いています:
C#コードを機械実行可能コードに変換することは2段階のプロセスであることを覚えておいてください。C#コンパイラはアセンブリで配信されるILを生成します。JITコンパイラは、必要に応じて各メソッド(またはインライン化が関与する場合はメソッドのグループ)の機械コードを生成します。小さな関数により、JITコンパイラがそのコストを償却することがはるかに容易になります。小さな関数はインライン化の候補にもなりやすいです。単に小ささだけではありません:よりシンプルな制御フローも同じくらい重要です。関数内の制御分岐が少ないほど、JITコンパイラが変数をレジスタに割り当てることが容易になります。これは単により明確なコードを書く良い実践ではありません;実行時により効率的なコードを作成する方法なのです。
循環的複雑度を理解するために、以下のメソッドは循環的複雑度が12です。
public string ComplexityOf12(int status)
{
var isTrue = true;
var myString = "Chuck";
if (isTrue)
{
if (isTrue)
{
myString = string.Empty;
isTrue = false;
for (var index = 0; index < 10; index++)
{
isTrue |= Convert.ToBoolean(new Random().Next());
}
if (status == 1 || status == 3)
{
switch (status)
{
case 3:
return "Bye";
case 1:
if (status % 1 == 0)
{
myString = "Super";
}
break;
}
return myString;
}
}
}
if (!isTrue)
{
myString = "New";
}
switch (status)
{
case 300:
myString = "3001";
break;
case 400:
myString = "4003";
break;
}
return myString;
}
一般的に受け入れられている複雑さ仮説では、複雑さと欠陥の間に正の相関があると仮定されています。
前の行は少し複雑です。最もシンプルな言葉で言うと — コードをシンプルに保つことで欠陥率が下がります。
2. テスト可能なコードを書く
研究により、実際のテストを書かずにテスト可能なコードを書くことで、欠陥の発生率が下がることが示されています。これは非常に重要で深遠なことなので繰り返す必要があります:実際のテストを書かずにテスト可能なコードを書くことで、欠陥の発生率が下がります。
これは疑問を投げかけます。テスト可能なコードとは何でしょうか?
私はテスト可能なコードを、分離してテストできるコードと定義しています。これは、すべての依存関係をテストからモックできることを意味します。依存関係の例はデータベースクエリです。テストでは、データがモック(偽装)され、期待される動作のアサーションが行われます。アサーションが真であればテストは合格し、そうでなければ失敗します。
テスト可能なコードを書くことは難しそうに聞こえるかもしれませんが、実際には制御の反転(依存性注入)とS.O.L.I.D原則に従えば簡単です。その容易さに驚き、なぜこの方法で書き始めるのにこんなに時間がかかったのか疑問に思うでしょう。
3. コードレビュー
開発チームが採用できる最も影響力のある実践の一つがコードレビューです。
コードレビューは開発者間の知識共有を促進します。経験から言うと、他の開発者とコードについてオープンに議論することが、私のコード記述スキルに最も大きな影響を与えました。
Steve McConnellによる著書Code Completeで、Steveはコードレビューの利点について多数のケーススタディを提供しています:
- 200人以上のAT&Tの組織の研究では、組織がレビューを導入した後、生産性が14%向上し、欠陥が90%減少したと報告されています。
- Aetna Insurance Companyは、インスペクションを使用してプログラムのエラーの82%を発見し、開発リソースを20%削減することができました。
- 同じグループの人々によって開発された11のプログラムのグループで、最初の5つはレビューなしで開発されました。残りの6つはレビューありで開発されました。すべてのプログラムが本番環境にリリースされた後、最初の5つは100行のコードあたり平均4.5のエラーがありました。インスペクションされた6つは平均わずか0.82のエラーでした。レビューによりエラーが80%以上削減されました。
これらの数字がコードレビューの採用を説得しないなら、あなたはJohnny PaycheckのTake This Job and Shove Itを歌いながらブラックホールに漂うことになるでしょう。
4. 単体テスト
正直に言うと、締切に追われているときは、テストが最初に削られるものです。しかし、以下の研究が示すように、テストの利点は否定できません。
Microsoftは単体テストの効果について研究を行いました。彼らは、自動テストを使用したバージョン2のコーディング(バージョン1にはテストがありませんでした)により、欠陥が即座に20%減少したが、追加で30%のコストがかかったことを発見しました。
別の研究では、テスト駆動開発(TDD)を調査しました。彼らは、TDDを使用していない類似のプロジェクトと比較して、コード品質が2倍以上向上することを観察しました。TDDプロジェクトは平均して開発に15%長い時間がかかりました。TDDの副作用として、テストがライブラリとAPIのドキュメントとして機能しました。
最後に、テストカバレッジと検証後欠陥に関する研究では:
… 両方のプロジェクトにおいて、テストカバレッジの増加は、リリース前変更数で調整した場合、フィールドで報告される問題の減少と関連していることがわかりました…
例
以下のコードは循環的複雑度が4です。
public void SendUserHadJoinedEmailToAdministrator(DataAccess.Database.Schema.dbo.Agency agency, User savedUser)
{
AgencySettingsRepository agencySettingsRepository = new AgencySettingsRepository();
var agencySettings = agencySettingsRepository.GetById(agency.Id);
if (agencySettings != null)
{
var newAuthAdmin = agencySettings.NewUserAuthorizationContact;
if (newAuthAdmin.IsNotNull())
{
EmailService emailService = new EmailService();
emailService.SendTemplate(new[] { newAuthAdmin.Email }, GroverConstants.EmailTemplate.NewUserAdminNotification, s =>
{
s.Add(new EmailToken { Token = "Domain", Value = _settings.Domain });
s.Add(new EmailToken
{
Token = "Subject",
Value =
string.Format("New User {0} has joined {1} on myGrover.", savedUser.FullName(), agency.Name)
});
s.Add(new EmailToken { Token = "Name", Value = savedUser.FullName() });
return s;
});
}
}
}
上記のコードのテスト可能性を検証してみましょう。
これはシンプルなコードですか?
はい、循環的複雑度が5未満です。
依存関係はありますか?
はい。AgencySettingsRepository
とEmailService
の2つのサービスがあります。
サービスはモック可能ですか?
いいえ、それらの作成はメソッド内に隠されています。
コードはテスト可能ですか?
いいえ、AgencySettingsRepository
とEmailService
をモックできないため、このコードはテスト可能ではありません。
リファクタリングされたコードの例
このコードをテスト可能にするにはどうすればよいでしょうか?
AgencySettingsRepository
とEmailService
を依存関係として(コンストラクタインジェクションを使用して)注入します。これにより、テストからそれらをモックし、分離してテストできます。
以下がリファクタリングされたバージョンです。
サービスがコンストラクタに注入されていることに注目してください。これにより、SendMail
コンストラクタに渡される実装を制御できます。その後、ダミーデータを渡し、サービスメソッドの呼び出しを傍受することが簡単になります。
public class SendEmail
{
private IAgencySettingsRepository _agencySettingsRepository;
private IEmailService _emailService;
public SendEmail(IAgencySettingsRepository agencySettingsRepository, IEmailService emailService)
{
_agencySettingsRepository = agencySettingsRepository;
_emailService = emailService;
}
public void SendUserHadJoinedEmailToAdministrator(DataAccess.Database.Schema.dbo.Agency agency, User savedUser)
{
var agencySettings = _agencySettingsRepository.GetById(agency.Id);
if (agencySettings != null)
{
var newAuthAdmin = agencySettings.NewUserAuthorizationContact;
if (newAuthAdmin.IsNotNull())
{
_emailService.SendTemplate(new[] { newAuthAdmin.Email },
GroverConstants.EmailTemplate.NewUserAdminNotification, s =>
{
s.Add(new EmailToken { Token = "Domain", Value = _settings.Domain });
s.Add(new EmailToken
{
Token = "Subject",
Value = string.Format("New User {0} has joined {1} on myGrover.", savedUser.FullName(), agency.Name)
});
s.Add(new EmailToken { Token = "Name", Value = savedUser.FullName() });
return s;
});
}
}
}
}
テストの例
以下は分離テストの例です。モッキングフレームワークFakeItEasyを使用しています。
[Test]
public void TestEmailService()
{
//Given
//FakeItEasyモッキングフレームワークを使用
var repository = A<IAgencySettingsRepository>.Fake();
var service = A<IEmailService>.Fake();
var agency = new Agency { Name = "Acme Inc." };
var user = new User { FirstName = "Chuck", LastName = "Conway", Email = "chuck.conway@fakedomain.com" }
//When
var sendEmail = new SendEmail(repository, service);
sendEmail.SendUserHadJoinedEmailToAdministrator(agency, user);
//Then
//これが呼び出されない場合、例外がスローされます。
A.CallTo(() => service.SendTemplate(A<Agency>.Ignore, A<User>.Ignore)).MustHaveHappened();
}
まとめ
欠陥に強いコードを書くことは驚くほど簡単です。誤解しないでください、欠陥のないコードを書くことは決してできません(もし方法を見つけたら、教えてください!)が、この記事で概説した4つの実践に従うことで、コードで発見される欠陥の減少を見ることができるでしょう。
要約すると、シンプルなコードを書くことは循環的複雑度を5程度に保ち、メソッドサイズを小さくすることです。テスト可能なコードを書くことは、制御の反転とS.O.L.I.D原則に従うことで簡単に達成できます。コードレビューは、あなたとチームがドメインと書いたコードを理解するのに役立ちます — コードを説明するだけで問題が明らかになります。そして最後に、単体テストはコード品質を劇的に改善し、将来の開発者にドキュメントを提供できます。
著者:Chuck Conwayはソフトウェアエンジニアリングと生成AIを専門としています。ソーシャルメディアで彼とつながりましょう:X (@chuckconway) または YouTube をご覧ください。