ツナ缶雑記

ぐうたらSEのブログです。主にマイクロソフト系技術を中心に扱います。たまに技術と関係ないことも書きます。

バグを減らすシンプルな方法 - 型安全なプログラミングのすすめ

この記事のまとめ

  • 型安全なプログラムを意識することで、データの渡し間違いによる論理バグを、未然に防ぐことができます。
  • 型安全なプログラムの実現には、 record 型または record struct 型の活用を推奨します。
  • 特に引数の数が多いメソッドでは、タイプセーフな設計が効果を発揮します。

組み込みの型を使うことの弊害

大規模な開発をしていると、よりタイプセーフな形でプログラムを開発したくなることがあります。 例えば以下のようなケースを考えてみてください。

int id = 1;
int stage = 4;

string message = GetMessage(id);
Console.WriteLine($"stage:{stage} {message}");

static string GetMessage(int messageId)
    => messageId switch
    {
        1 => "Hello",
        2 => "Goodbye",
        _ => "I don't know"
    };

GetMessage メソッドは messageId を指定してメッセージを取得する機能を持ちます。 引数には int 型の値を渡せます。 上記のプログラムは全く問題なく動作しますが、引数に任意の int 型の値を渡せるため、論理バグを引き起こしやすい構造になっています。

例えば以下のようなプログラムを考えてみます。

int id = 1;
int stage = 4;
string message = GetMessage(stage); // 誤ってstageを渡してしまった

上記のようコーディングしても、プログラム上は int 型の値を渡しており、コンパイルも実行もできてしまいます。 ただ、実行結果に論理的な誤りがある状態であり、正しいプログラムではありません。

このような問題を、プログラムの作り方で改善するひとつの方法が、タイプセーフ(型安全)という考え方です。

タイプセーフにする方法

先ほどの例で問題を引き起こす根本的な原因は、 GetMessage メソッドの引数に、汎用的な型である int 型を使っていることにあります。 ここにメッセージIDを表す専用の型を導入することで、誤った値を指定するリスクを格段に減らせます。 C#の場合、 record 型または record struct 型を使うとうまく解決できます。

MessageId id = new MessageId(1);
int stage = 4;

string message = GetMessage(id); // MessageId以外は渡せない
Console.WriteLine($"stage:{stage} {message}");

static string GetMessage(MessageId messageId)
    => messageId.Value switch
    {
        1 => "Hello",
        2 => "Goodbye",
        _ => "I don't know"
    };

record struct MessageId(int Value);

この例ではメッセージIDを表す専用の型を record struct で作成しています。 GetMessage メソッドの引数にその専用型を使用し、メッセージID以外が指定されないように型のレベルで強制しています。

タイプセーフであることのメリット

タイプセーフであることのメリットは、メソッドの引数が複雑になればなるほど発揮されます。 例えば以下のようなメソッドを考えてみます。

static decimal GetSubTotal(
    int productId,
    int amount,
    int taxCategory,
    int discountId)
{
    // do something
}

このメソッドは4つの int 型引数を取ります。 すべて型が同じであるため、呼び出す側は引数の設定誤りがないように注意しながらコーディングするはずです。 またできあがったプログラムをコードレビュー等で確認するときも、本当に正しく引数を指定できているか、確認する負荷が高くなります。

int productId = 123;
int amount = 2;
int taxCategory = 1;
int discountId = 0;

GetSubTotal(productId, amount, discountId, taxCategory);

実際上記のプログラムは、引数の設定誤りがあります。 これを瞬時に判断するのは、もはや不可能と言えるのではないでしょうか。

それぞれの引数に専用型を作ると、以下のようなプログラムになります。

var productId = new ProductId(123);
var amount = new Amount(2);
var taxCategory = new TaxCategory(1);
var discountId = new DiscountId(0);

GetSubTotal(productId, amount, taxCategory, discountId);

static decimal GetSubTotal(
    ProductId productId,
    Amount amount,
    TaxCategory taxCategory,
    DiscountId discountId)
{
    // do something
}

record struct ProductId(int Value);
record struct Amount(int Value);
record struct TaxCategory(int Value);
record struct DiscountId(int Value);

引数の順序を間違えたとしても、コンパイルエラーで不具合を検出できてしまうのです。 プログラムが複雑になればなるほど、効果を発揮する方法だと言えます。

タイプセーフであることのデメリット

タイプセーフなプログラムを作成しようとすると、必要となるプログラムの記述量が多少多くなります。 今回ご紹介した簡易な方法であれば、ほんのわずかな手間でタイプセーフを保証できますが、突き詰めようとするとまだまだできることが存在します。

また通常このような専用型は、データベースやWeb APIなど、外部システムと連携する際、何かしらの型変換を実装しなければなりません。 ここにも、多少の手間の増大があるのです。

まとめ

タイプセーフによって享受できるメリットと手間の増大、どちらに重きを置くかで、採用の判断が変わると思います。 銀の弾丸ではないですし、状況に合わせて使い分けるべきものとも思います。 一部の重要な型のみタイプセーフにする、という判断もあるべきです。

食わず嫌いをせずに、一度試してみることを個人的にはおすすめします。 一度タイプセーフの世界を味わうと、抜け出せなくなるのではないかと想像しています。 実際に私は、複雑なプログラムを書く際、タイプセーフであることを優先することが多いです。 ぜひ一度試して、感覚を得てもらえればと思います。