アーティサン株式会社
  • 技術ブログ

C# に union がやってくる! 〜共用体型を使ってみる〜

この記事の4行要約

  • C# 15(.NET 11)で導入される共用体型(Union Types)は、あらかじめ指定した複数の型のうち、いずれか1つの値を持てる型です
  • switch 式で共用体型を扱うと、処理していない型があるときにコンパイラが警告(CS8509)を出す「網羅性チェック」が働きます
  • object 型・インターフェイス・自前の Result 型と比べて、「この型にはこの種類しか入らない」というルールを型そのもので表現できます
  • 本記事では、.NET 11 RC2 で実際にコードを動かしながら、API の戻り値やマーカーインターフェイスの置き換えなど、実務での使いどころを解説します
この記事を書いた人
宮里 匡

宮里 匡(みやみや)

開発組織マネジメント バスロケーションシステム プロダクト開発

モビリティ領域の開発責任者として、チームのマネジメントやリリース判断、営業・運用部門との橋渡しを担っています。開発現場にも必要に応じて関わりながら、Azureを中心としたクラウド基盤や情報セキュリティも含め、プロダクトを継続的に運営できる環境づくりに取り組んでいます。

UD検定・中級 ITコーディネータ 色彩検定 UC級 PMC

アーティサンのモビリティ事業部では、バスロケーションシステム『バス予報』をはじめ、予約配車やGTFS作成支援、お知らせ配信などのサービスを自社で開発・運用しています。気になるサービスがあれば、下記からぜひご確認ください。

 

みやみやです。

モビリティ事業部の開発チームでは、『バス予報』の開発・運用に取り組みながら、日々新しい技術を試したり、検証したりしています。

この技術ブログでは、そうした技術的な取り組みの一部をご紹介していきます。今回は、C# の新しい機能を取り上げます。

C# で開発していると、

「この変数には、A、B、C のいずれかの型しか入らないようにしたい!」

と思うことってありませんか?

これまでも object 型やインターフェイス、継承などを使えば似たようなことはできたのですが、少し工夫が必要でした。

そんな C# に、.NET 11(C# 15)から 共用体型(Union Types) というものが導入されます。

TypeScript を使っている方にはおなじみの考え方かもしれませんね。

個人的にも気になっていた機能なので、今回は実際に動かしながら、何が嬉しいのか、どんなところで使えそうなのかを考えてみたいと思います!

 

共用体型(Union Types)って何?

まずは、共用体型についてザックリと説明します。

共用体型とは、あらかじめ指定した複数の型の中から、いずれか1つの値を持つことができる型 です。

文章にすると分かりにくいので、簡単な例を見てみましょう。

例えば、猫(Cat)、犬(Dog)、鳥(Bird)という3つの型があるとします。

public sealed record class Cat(string Name);
public sealed record class Dog(string Name);
public sealed record class Bird(string Name);

この3つの型を「ペット(Pet)」という1つの型として扱いたい場合、共用体型を使うと、次のように書けます。

public union Pet(Cat, Dog, Bird);

はい、これだけです!

Pet には Cat、Dog、Bird のいずれかを入れることができますが、それ以外の型を入れることはできません。

ちなみに、今回のサンプルでは sealed を付けているので、それぞれの型を継承して新しい型を作ることもできません。

つまり、「この型には、この3種類のどれかしか入らないよ!」というルールを、型そのもので表現できる わけです。

この辺りが、これまでの C# とは少し違うところですね。

 

実際に試してみましょう

説明だけでは面白くないので、実際に動かしてみましょう。

 

開発環境の準備

まずは .NET 11 の SDK をインストールしておきます。

今回は .NET 11 RC2 を使う前提で進めます。

適当なフォルダーを作って、コンソールアプリケーションを作成します。

mkdir MyApp
cd MyApp
dotnet new console

次に、MyApp.csproj を開いて、ターゲットフレームワークを net11.0 にしておきます。

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net11.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

ちなみに、初期のプレビュー版では <LangVersion>preview</LangVersion> を追加する必要がありましたが、RC1 以降では C# 15 が標準になったので不要になっています。

この辺りは使っている SDK のバージョンによって異なるので注意してください。

 

Pet 型を使ってみる

では、Program.cs を次のように書き換えてみましょう。

public sealed record class Cat(string Name);
public sealed record class Dog(string Name);
public sealed record class Bird(string Name);

public union Pet(Cat, Dog, Bird);

class Program
{
    static void Main()
    {
        Pet myPet = new Cat("タマ");

        var message = myPet switch
        {
            Cat c => "猫です",
            Dog d => "犬です",
            Bird b => "鳥です"
        };

        Console.WriteLine(message);
    }
}

Pet 型の変数に、Cat 型のインスタンスを代入しています。

また、switch 式で中身がどの型なのかを判定しています。

では、実行してみましょう!

$ dotnet run
猫です

はい!ちゃんと猫だと判定されましたねw

ちなみに、Cat を Dog に変更すれば「犬です」、Bird に変更すれば「鳥です」と表示されます。

ここまでは、従来のインターフェイスなどでも似たようなことができそうですが、共用体型の面白いところはここからです。

 

switch 式で処理の漏れをチェックしてくれる

個人的に、共用体型で嬉しいと思ったのが、この 網羅性チェック です。

先ほどのコードでは、switch 式で Cat、Dog、Bird の3つを判定していました。

では、Bird の処理を消してみましょう。

var message = myPet switch
{
    Cat c => "猫です",
    Dog d => "犬です"
};

Pet 型には Bird が含まれているのに、switch 式では Bird を処理していません。

この状態でビルドすると、コンパイラが警告を出してくれます。

例えば、次のような警告です。

warning CS8509:
この switch 式では入力型の可能な値が
すべて扱われるわけではありません。
たとえば、パターン 'Bird' がカバーされていません。

素晴らしいですね!

つまり、共用体型に新しい型を追加したときなどに、処理の追加を忘れている場所をコンパイラが教えてくれるわけです。

しかも、すべての型を網羅していれば、通常は switch 式の最後に書いていた _ => のような分岐も不要になります。

ちなみに、union で宣言した共用体型は、内部的には構造体として生成されます。

そのため、次のように default で初期化すると、中身の Value が null になります。

Pet pet = default;

Console.WriteLine(pet.Value is null); // True

このような場合には、switch 式でも null の処理が必要になることがあります。

この辺りは少し注意が必要ですね。

なお、これは switch 式の網羅性チェック の話です。switch 文もまったく同じように扱われるわけではないので、その点は注意してください。

 

共用体型にはメンバーも追加できます

共用体型は、型をまとめるだけではなく、メソッドや計算プロパティなどのメンバーを追加することもできます。

例えば、先ほどの switch 式を使った判定処理を Pet 型自身に持たせてみましょう。

public sealed record class Cat(string Name);
public sealed record class Dog(string Name);
public sealed record class Bird(string Name);

public union Pet(Cat, Dog, Bird)
{
    public string Message => this switch
    {
        Cat c => $"猫({c.Name})です",
        Dog d => $"犬({d.Name})です",
        Bird b => $"鳥({b.Name})です",
        null => throw new InvalidOperationException(
            "Pet の中身が null です。")
    };
}

class Program
{
    static void Main()
    {
        Pet myPet = new Cat("タマ");

        Console.WriteLine(myPet.Message);
    }
}

実行結果は次のようになります。

猫(タマ)です

これなら呼び出し側で毎回 switch 式を書く必要がありません。

また、判定対象の型が増えた場合も、関連するロジックが型の近くにまとまっているので、変更する場所が分かりやすくなります。

こういう書き方もできるんですね。

 

これまでの C# ではどうしていたのか?

ここまで共用体型を使ってみましたが、そもそも、これまではどうやって実現していたのでしょうか?

代表的な方法を少し振り返ってみましょう。

 

object 型を使う

まず、一番簡単なのは object 型を使う方法です。

object myPet = new Cat("タマ");

object 型は何でも入れられるので、当然 Cat も Dog も Bird も入ります。

ただ、何でも入るということは、逆に言えば 入ってほしくない型まで入ってしまう ということです。

例えば、間違えて string 型を代入してもコンパイルできてしまいます。

結局、使う側で型をチェックしたり、キャストしたりする必要があります。

 

抽象クラスやインターフェイスを使う

次に考えられるのが、抽象クラスやインターフェイスを使う方法です。

public interface Pet { }

public sealed record class Cat(string Name) : Pet;
public sealed record class Dog(string Name) : Pet;
public sealed record class Bird(string Name) : Pet;

これなら Pet 型として、3つの型をまとめて扱うことができます。

ただし、インターフェイスには少し困ったところがあります。

それは、後から別の型を実装できてしまう ということです。

例えば、別の開発者が Fish というクラスを追加して Pet を実装しても、問題なくコンパイルできてしまいます。

もちろん、それ自体はインターフェイスの便利なところなのですが、

「Cat、Dog、Bird の3つだけに限定したい!」

という場合には、ちょっと困りますよね。

 

自前の Result 型を作る

もう1つ、API などの戻り値でよくあるのが、成功と失敗を表現するための Result<T> 型を作る方法です。

public struct Result<T>
{
    public bool IsSuccess { get; }
    public T Value { get; }
    public string Error { get; }
}

こんな感じですね。

※ 説明を簡単にするため、コンストラクターなどの実装は省略しています。

IsSuccess が true なら Value を参照して、false なら Error を参照する、といった使い方です。

ただ、この実装だと、成功しているのに Error にアクセスしたり、失敗しているのに Value にアクセスしたりするコードも書けてしまいます。

「成功したら Value がある、失敗したら Error がある」

というルールは、開発者が守る必要があります。

もちろん、作り方を工夫すれば安全性を高めることはできますが、そこまで自分で実装する必要があるわけです。

共用体型を使えば、こういった 「どれか1つの状態しか持たない」 ということを、もっとシンプルに表現できるようになります。

 

実務ではどんなところで使えそう?

さて、ここからは実務での使い道を考えてみたいと思います。

 

API の戻り値を表現する Result 型

まず真っ先に思いついたのが、API やアプリケーションサービスの戻り値です。

例えば、ユーザーを登録する処理を考えてみましょう。

ユーザー登録には、いくつかの結果が考えられます。

  • 正常にユーザーが作成された

  • 入力内容に問題があり、バリデーションエラーになった

  • 既に同じメールアドレスが登録されていた

こういった結果を、共用体型を使って表現してみます。

Program.cs

public record class CreatedUser(string Name, string Email);
public record class ValidationError(string Message);
public record class DuplicatedEmail(string Email);

public union CreateUserResult(
    CreatedUser,
    ValidationError,
    DuplicatedEmail
);

class Program
{
    static void Main(string[] args)
    {
        var name = args.Length > 0 ? args[0] : "";
        var email = args.Length > 1 ? args[1] : "";

        CreateUserResult result = CreateUser(name, email);

        var response = result switch
        {
            CreatedUser user =>
                $"User created: {user.Name} ({user.Email})",

            ValidationError error =>
                $"Validation error: {error.Message}",

            DuplicatedEmail duplicated =>
                $"Email already exists: {duplicated.Email}",

            null => "Unexpected error: result is null"
        };

        Console.WriteLine(response);
    }

    static CreateUserResult CreateUser(
        string name, string email)
    {
        if (string.IsNullOrWhiteSpace(name))
        {
            return new ValidationError(
                "Name cannot be empty.");
        }

        if (string.IsNullOrWhiteSpace(email))
        {
            return new ValidationError(
                "Email cannot be empty.");
        }

        if (email == "taro@example.com")
        {
            return new DuplicatedEmail(email);
        }

        return new CreatedUser(name, email);
    }
}

ポイントは、CreateUser メソッドの戻り値です。

static CreateUserResult CreateUser(
    string name, string email)

戻り値が CreateUserResult なので、このメソッドが返す可能性のある結果は、

  • CreatedUser

  • ValidationError

  • DuplicatedEmail

のいずれかだと分かります。

また、呼び出し側では switch 式を使って、それぞれの結果に応じた処理を行っています。

早速、実行してみましょう!

正常に登録できた場合

$ dotnet run "花子" "hanako@example.com"
User created: 花子 (hanako@example.com)

名前が入力されていない場合

$ dotnet run "" "hanako@example.com"
Validation error: Name cannot be empty.

メールアドレスが重複した場合

$ dotnet run "太郎" "taro@example.com"
Email already exists: taro@example.com

はい!それぞれ想定した結果になりました。

個人的には、これがかなり気持ちいいですw

業務アプリケーションを作っていると、例外として扱うほどではないけれど、正常終了とも言えない結果って結構ありますよね。

そういった 「想定内の失敗」も戻り値の型で表現できる のは、なかなか便利だと思います。

もちろん、すべての例外処理を共用体型に置き換えればよいという話ではありません。

ただ、業務ロジックとして想定される結果を明示するには、かなり相性が良さそうです。

 

マーカーインターフェイスの代わりに使う

もう1つ、使い道として考えられるのが、マーカーインターフェイスの置き換えです。

マーカーインターフェイスとは、メソッドやプロパティを持たない空のインターフェイスで、オブジェクトを分類するための目印として使用します。

例えば、メールと Slack の通知先を表現する場合、こんな書き方ができます。

public interface INotificationTarget { }

public record class EmailTarget(string Email) : INotificationTarget;
public record class SlackTarget(string Channel) : INotificationTarget;

これでも問題なさそうですが、別の開発者が SmsTarget などを追加することもできてしまいます。

もちろん、後から自由に種類を増やせるのはインターフェイスのメリットなのですが、通知先を限定したい場合には少し困りますね。

そこで共用体型を使ってみます。

public record class EmailTarget(string Email);
public record class SlackTarget(string Channel);

public union NotificationTarget(EmailTarget, SlackTarget);

はい、スッキリしました!

これなら NotificationTarget には EmailTarget と SlackTarget のどちらかしか入らなくなります。

また、新しい通知先を共用体型に追加した際、switch 式で処理を追加し忘れているとコンパイラが警告してくれます。

インターフェイスが不要になるという話ではなく、「後から自由に種類を増やしたい」のか、「あらかじめ種類を限定したい」のか で使い分けると良さそうですね。

 

共用体型の何が嬉しいのか?

ここまでいくつか試してみましたが、私が感じたメリットをまとめてみます。

 

1. 処理の考慮漏れに気づきやすい

やはり、一番大きいのは switch 式の網羅性チェックだと思います。

業務システムでは、新しい状態や処理を追加することがよくあります。

そんなとき、型は追加したけれど、それに対応する処理を追加し忘れた、なんてことが起こりがちです。

共用体型なら、こういった変更に対してコンパイラが警告を出してくれます。

もちろん、コンパイラがすべてのバグを見つけてくれるわけではありませんが、実行する前に気づける問題が増えるのはありがたいですね。

 

2. 想定内のエラーを型で表現できる

もう1つは、業務ロジック上のエラーを型として表現できることです。

例えば、ユーザー登録の処理で、

「登録できました」

「入力内容が間違っています」

「既に登録されています」

といった結果を、それぞれ異なる型として表現できます。

これなら、メソッドの戻り値を見るだけで、どんな結果が返ってくる可能性があるのかが分かります。

例外を投げて呼び出し側に判断させたり、null を返して意味を判断させたりするよりも、コードの意図が分かりやすくなる場面は多そうです。

 

3. 型の設計意図が伝わりやすい

個人的には、これも結構大きいと思っています。

コードを書いた本人は、

「ここにはこの3種類しか入らない想定だから!」

と思っていても、他の人が同じことを理解しているとは限りません。

コメントに書いておくこともできますが、コメントはあくまでコメントです。

共用体型なら、型定義自体にそのルールを書けます。

「ここには、この種類のデータしか入らない」

という設計意図を、コンパイラにも他の開発者にも伝えられるのは、なかなか良いのではないでしょうか。

 

まとめ

いかがでしたでしょうか?

今回は、.NET 11(C# 15)で導入される共用体型(Union Types)について、実際にコードを書きながら試してみました。

共用体型を使うことで、これまでは object やインターフェイス、自前の Result<T> などで表現していた処理を、よりシンプルに書ける場面が増えそうです。

特に、

「取りうる状態を型で限定できる」

「switch 式での処理漏れに気づきやすくなる」

という2点は、実務でもかなり嬉しい機能だと感じました。

個人的には、API の戻り値や業務処理の結果を表現するようなところから使ってみたいですね。

もちろん、新しい機能だからといって、何でも共用体型にする必要はありません。

既存のインターフェイスや継承の方が適している場面もありますし、用途に応じて使い分けていくことになるかと思います。

また、記事執筆時点では .NET 11 は正式リリース前なので、今後変更される可能性もあります。

とはいえ、C# でこういった型の表現ができるようになるのは、個人的にはかなり嬉しい進化です。

今まで「こういう型があったら便利なのになあ」と思っていたところが、少しずつ言語の機能として実現されていくのは面白いですね。

正式リリースが楽しみです!

 

参考資料

最後までお読みいただきありがとうございました。モビリティ事業部の技術ブログでは、日々の開発で触れている技術や検証の内容をお届けしていきます。『バス予報』をはじめ、地域交通の課題解決につながる各種サービスも、ぜひあわせてご覧ください。

 

バスロケ「バス予報」操作説明動画を公開しています

YouTube で操作説明動画も公開しています。
もう少し詳しく知りたい方はそちらもぜひご覧ください!

 

アーティサンはICT企業として公共交通の利用者と関わる事業者様の笑顔のために公共交通事業者様向けの製品やソリューション、コンサルティングを提供しています。

利用者や公共交通事業者に「低価格」「高機能」「使いやすい」をモットーとしたよりよいシステムを提供しております。

お気軽にアーティサン株式会社までお問い合わせください。