【C#】拡張メソッドについて

【C#】拡張メソッドについて

はじめに

今回は C# の機能である拡張メソッドについて解説します。

コードの検証として paiza.oi を使用しています。

拡張メソッドとは?

拡張メソッドは C# 3.0 か使用可能になった機能で、既存の型に対してあたかもインスタンスメソッドを追加(拡張)したかのよう動作させることが出来ます。

そのため元のコードを変更することなく、既存のクラスやインターフェースに対して新しい機能を追加することが出来ます。

拡張メソッドは以下のようにstaticなクラス、メソッドを用意します。

public static class HelloWorldExtensions
{
    public static string AddHelloWorld(this string str)
    {
        return $"{str} Hello World";
    }
}

構文は以下のような意味になります。

public static class 拡張メソッドクラス名
{
    public static 戻り値の型 メソッド名(this 拡張対象のクラス 引数名)
    {
        // メソッドの処理
    }
}

this 拡張対象のクラスと指定することで、拡張対象の型にこのメソッドが利用できるようになります。

今回の例で言えばstring型AddHelloWorldメソッドが呼べるようになります。

using System;

public class Hello{
    public static void Main(){

        var result = "こんにちは";

        Console.WriteLine(result.AddHelloWorld());
    }
}

public static class HelloWorldExtensions
{
    public static string AddHelloWorld(this string str)
    {
        return $"{str} Hello World"; // こんにちは Hello World
    }
}

result.AddHelloWorld()のようにインスタンスメソッドと同じ用に呼び出すことができます。

また、通常の静的メソッドのように呼び出すことも出来ます。

using System;

public class Hello{
    public static void Main(){

        var result = "こんにちは";

        // 静的メソッドのように呼び出すことも出来る
        Console.WriteLine(HelloWorldExtensions.AddHelloWorld(result));
    }
}

public static class HelloWorldExtensions
{
    public static string AddHelloWorld(this string str)
    {
        return $"{str} Hello World";
    }
}

名前空間の衝突に注意

以下のように同じ名前空間に同名の拡張メソッドが存在する場合エラーになります。

using System;
using HelloWorldProgram; // 追加

public class Hello{
    public static void Main(){

        var result = "こんにちは";

        Console.WriteLine(result.AddHelloWorld()); // エラー
    }
}

namespace HelloWorldProgram // 名前空間を定義
{
    public static class HelloWorldExtensions
    {
        public static string AddHelloWorld(this string str)
        {
            return $"{str} Hello World";
        }
    }

    public static class HelloWorldExtensions1
    {
        public static string AddHelloWorld(this string str)
        {
            return $"{str} Hello World";
        }
    }
}

拡張メソッドの呼び出しはusing ディレクティブで追加された名前空間内にある拡張メソッドが参照されるため、今回の例で言えば同じクラス内に完全に同じシグネチャ(戻り値の型、メソッド名、引数)のメソッドを定義した時と同じような実装となりエラーとなります。

インスタンスメソッドが優先される

同じ名前のメソッドがいくつか定義されていた場合、インスタンスメソッドが優先されて呼び出されるルールがります。

using System;
using HelloWorldProgram;

public class Hello{
    public static void Main(){

        var obj = new HelloWorldClass();

        Console.WriteLine(obj.HelloWorld()); // インスタンスメソッド
    }
}

namespace HelloWorldProgram
{
    public static class HelloWorldExtensions
    {
        public static string HelloWorld(this HelloWorldClass obj) // 参照先を HelloWorldClass クラスに変更
        {
            return "拡張メソッド";
        }
    }

    public class HelloWorldClass
    {
        public string HelloWorld()
        {
            return "インスタンスメソッド";
        }
    }
}

コンパイル時に、型自体で定義されているインスタンス メソッドよりも低い優先順位が拡張メソッドには必ず設定されます。 つまり、型に Process(int i) という名前のメソッドがあり、これと同じシグネチャの拡張メソッドがある場合、コンパイラは必ずインスタンス メソッドにバインドします。 コンパイラは、メソッド呼び出しを検出すると、最初に型のインスタンス メソッドから一致するものを探します。 一致するものが見つからない場合、型に対して定義されている拡張メソッドを検索し、見つかった最初の拡張メソッドにバインドします。

拡張メソッドの使い所

拡張メソッドの使い方について確認してきましたが、具体的にどのようなケースで使用するのが良いのか見ていきます。

色々なケースがあると思いますが、あくまで個人的によく使う方法の一例です。

ValueObject と相性が良い

今回 ValueObject について詳細な解説は割愛しますが、ざっくり以下の特徴を持つ OOP における設計パターンの一つです。

  • intstringDateTimeなどのようなプリミティブな値をクラスとして保持する
  • イミュータブル(不変性)であること
  • 保持した値を変更するメソッドは持たず、更新時には新しいValueObjectを作成する
  • 値を加工するビジネスロジックを含む

今回はサンプルとして、以下のような顧客情報を管理しているアプリケーションを例に見ていきます(※顧客情報は個人情報テストデータジェネレーターで作成したダミーデータです)

sample-application.png

DataGrid コントロールにはデータベース(SQLite)から取得したデータをUserEntityクラスに格納し表示しています。

"Entities/UserEntity.cs"
public sealed class UserEntity
{
    public long Id { get; }
    public string Name { get;  }
    public string NameHiragana { get; }
    public long Age { get; }
    public string Birthdate { get; }
    public string Gender { get; }
    public string Email { get; }
    public string PhoneNumber { get; }
    public string PostalCode { get; }
    public string Address { get; }
    public string CompanyName { get; }
    public string CreditCardNumber { get; }
    public string ExpirationDate { get; }
    public string MyNumber { get; }
    public string CreatedAt { get; }
    public string UpdatedAt { get; }

    public UserEntity(long id,
                      string name,
                      string name_hiragana,
                      long age,
                      string birthdate,
                      string gender,
                      string email,
                      string phone_number,
                      string postal_code,
                      string address,
                      string company_name,
                      string credit_card_number,
                      string expiration_date,
                      string my_number,
                      string created_at,
                      string updated_at)

    {
        Id = id;
        Name = name;
        NameHiragana = name_hiragana;
        Age = age;
        Birthdate = birthdate;
        Gender = gender;
        Email = email;
        PhoneNumber = phone_number;
        PostalCode = postal_code;
        Address = address;
        CompanyName = company_name;
        CreditCardNumber = credit_card_number;
        ExpirationDate = expiration_date;
        MyNumber = my_number;
        CreatedAt = created_at;
        UpdatedAt = updated_at;
    }
}

また、これらの値はViewで以下のようにバインディングされているものとします。

"Views/MainWindow.xaml"

<DataGrid x:Name="membersDataGrid"
          Grid.Row="1"
          ItemsSource="{Binding FilterMemberData}"
          ScrollViewer.HorizontalScrollBarVisibility="Auto">
    <DataGrid.Columns>

    <!-- 省略 -->

    <DataGridTextColumn Width="Auto"
                        Binding="{Binding PostalCode}"
                        Header="郵便番号"
                        IsReadOnly="True" />

    <DataGridTextColumn Width="Auto"
                        Binding="{Binding Address}"
                        Header="住所"
                        IsReadOnly="True" />

    <DataGridTextColumn Width="Auto"
                        Binding="{Binding CompanyName}"
                        Header="会社名"
                        IsReadOnly="True" />

    <DataGridTextColumn Width="Auto"
                        Binding="{Binding CreatedAt}"
                        Header="登録日"
                        IsReadOnly="True" />

    <!-- 省略 -->

    </DataGrid.Columns>
</DataGrid>

このアプリケーションを作成後、以下の要求が発生しました。

  • 郵便番号には先頭に記号を付けて表示してほしい
  • 登録日は和暦で表示してほしい「令和 ● 年 ● 月 ●● 日」のフォーマット
  • 登録日は帳票出力時は「令和 ● 年度」というフォーマットで出力したい

これらの要求を保守性を考慮して実装するために、まずは日付に関するValueObjectを作成します。

"ValueObjects/DisplayDate.cs"

// 省略

public sealed class DisplayDate : ValueObject<DisplayDate>
{
    /// <summary>
    /// 西暦表示(加工なし)
    /// </summary>
    public string Value { get; }

    /// <summary>
    /// コンストラクタ
    /// </summary>
    public DisplayDate(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            throw new ArgumentException("日付を入力してください。");
        }

        Value = value;
    }

    /// <summary>
    /// 値の比較用メソッド
    /// </summary>
    protected override bool EqualsCore(DisplayDate other)
    {
        return Value == other.Value;
    }
}

SQLite を使用しているためDateTime型ではなくstring型となっています。SQL Server や MySQL を使用している場合は適宜変更してください。

次に継承しているValueObject<T>(基底クラス)を作成します。

これはValueObject内の値を比較するための共通処理を含んだクラスです。

"ValueObjects/ValueObject.cs"

// 省略

/// <summary>
/// ValueObjectの基底クラス(値比較用)
/// </summary>
/// <typeparam name="T"></typeparam>
public abstract class ValueObject<T> where T : ValueObject<T> // クラスを制約する
{
    /// <summary>
    /// ValueObjectを継承しているクラスの値を比較する
    /// </summary>
    /// <param name="obj">obj</param>
    /// <returns></returns>
    public override bool Equals(object obj)
    {
        // 引数で渡ってきたオブジェクトが変換出来るか確認する
        var vo = obj as T;

        // 変換できなければ違うクラスと判定
        if (vo == null)
        {
            return false;
        }

        // 基底クラスにはValueプロパティは無いので継承側のクラスで実装してもらう
        return EqualsCore(vo);
    }

    /// <summary>
    /// EqualsCore
    /// </summary>
    /// <param name="other">other</param>
    /// <returns></returns>
    protected abstract bool EqualsCore(T other);

    /// <summary>
    /// イコールの演算子をクラス比較用にオーバーライド
    /// ※ == をオーバーライドすると != もオーバーライドしないとコンパイルエラーになる
    /// </summary>
    /// <param name="vo1"></param>
    /// <param name="vo2"></param>
    /// <returns></returns>
    public static bool operator ==(ValueObject<T> vo1, ValueObject<T> vo2)
    {
        return Equals(vo1, vo2);
    }

    /// <summary>
    /// !=
    /// </summary>
    /// <param name="vo1"></param>
    /// <param name="vo2"></param>
    /// <returns></returns>
    public static bool operator !=(ValueObject<T> vo1, ValueObject<T> vo2)
    {
        return !Equals(vo1, vo2);
    }

    public override string ToString()
    {
        // 演算子をオーバーライドした際にこのメソッドもオーバーライドしないと警告が出る
        return base.ToString();
    }

    public override int GetHashCode()
    {
        // 演算子をオーバーライドした際にこのメソッドもオーバーライドしないと警告が出る
        return base.GetHashCode();
    }
}

UserEntityクラスのプロパティにDisplayDateクラスを適応させます。

"Entities/UserEntity.cs"
public sealed class UserEntity
{
    // 省略
-   public string CreatedAt { get; }
+   public DisplayDate  CreatedAt { get; }
    public string UpdatedAt { get; }

    public UserEntity(long id,
                      // 省略
                      string created_at,
                      string updated_at)

    {
        // 省略

-       CreatedAt = created_at;
+       CreatedAt = new DisplayDate(created_at);
        UpdatedAt = updated_at;
    }
}

Viewでバインディングしているプロパティも修正します。

"Views/MainWindow.xaml"

<DataGrid x:Name="membersDataGrid"
          Grid.Row="1"
          ItemsSource="{Binding FilterMemberData}"
          ScrollViewer.HorizontalScrollBarVisibility="Auto">
    <DataGrid.Columns>

    <!-- 省略 -->

    <DataGridTextColumn Width="Auto"
                        Binding="{Binding PostalCode.Value}"
                        Header="郵便番号"
                        IsReadOnly="True" />

    <DataGridTextColumn Width="Auto"
                        Binding="{Binding Address}"
                        Header="住所"
                        IsReadOnly="True" />

    <DataGridTextColumn Width="Auto"
                        Binding="{Binding CompanyName}"
                        Header="会社名"
                        IsReadOnly="True" />

    <DataGridTextColumn Width="Auto"
-                       Binding="{Binding CreatedAt}"
+                       Binding="{Binding CreatedAt.Value}"
                        Header="登録日"
                        IsReadOnly="True" />

    <!-- 省略 -->

    </DataGrid.Columns>
</DataGrid>

まだ西暦表示のままなので、和暦表示にするための拡張メソッドを作成しValueObjectに追加します。

"Extensions/JapaneseEraExtend.cs"

// 省略

/// <summary>
/// 日付の変換に関する拡張メソッド
/// </summary>
public static class JapaneseEraExtend
{
    /// <summary>
    /// 西暦の日付を和暦「"令和●年xx月xx日"」に変換
    /// </summary>
    public static string ConvertJapaneseEraTodateFullFromat(this string christianEra)
    {
        var cultureInfo = new CultureInfo("ja-JP");
        cultureInfo.DateTimeFormat.Calendar = new JapaneseCalendar();

        // 日付フォーマット
        const string format = "yyyy/M/d H:mm";
        // 渡ってきた値がフォーマットの形式と一致するか確認
        if (DateTime.TryParseExact(christianEra, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime date))
        {
            // 和暦のフォーマットに変換して返す
            return date.ToString("ggy年M月d日", cultureInfo);
        }

        // 解析できなかった場合の処理
        throw new ArgumentException("和変換に失敗しました。", nameof(christianEra));
    }

    /// <summary>
    /// 西暦の日付を和暦「"令和●年度"」に変換
    /// </summary>
    public static string ConvertJapaneseEraToFiscalYear(this string christianEra)
    {
        var cultureInfo = new CultureInfo("ja-JP");
        cultureInfo.DateTimeFormat.Calendar = new JapaneseCalendar();

        const string format = "yyyy/M/d H:mm";
        if (DateTime.TryParseExact(christianEra, format, CultureInfo.InvariantCulture, DateTimeStyles.None, out DateTime date))
        {
            return date.ToString("ggy年度", cultureInfo);
        }

        throw new ArgumentException("和変換に失敗しました。", nameof(christianEra));
    }
}

DisplayDateクラスを以下のように修正します。

"ValueObjects/DisplayDate.cs"

// 省略

public sealed class DisplayDate : ValueObject<DisplayDate>
{
    /// <summary>
    /// 西暦表示(加工なし)
    /// </summary>
    public string Value { get; }

    /// <summary>
    /// 西暦の日付を和暦「"令和●年xx月xx日"」に変換
    /// </summary>
+   public string DisplayJapaneseEraFullFromat => Value.ConvertJapaneseEraTodateFullFromat();

    /// <summary>
    /// 西暦の日付を和暦「"令和●年度"」に変換
    /// </summary>
+   public string DisplayJapaneseEraFiscalYear => Value.ConvertJapaneseEraToFiscalYear();

    /// <summary>
    /// コンストラクタ
    /// </summary>
    public DisplayDate(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
        {
            throw new ArgumentException("日付を入力してください。");
        }

        Value = value;
    }

    /// <summary>
    /// 値の比較用メソッド
    /// </summary>
    protected override bool EqualsCore(DisplayDate other)
    {
        return Value == other.Value;
    }
}

最後にViewでバインディングしているプロパティも修正します。

"Views/MainWindow.xaml"

<DataGrid x:Name="membersDataGrid"
          Grid.Row="1"
          ItemsSource="{Binding FilterMemberData}"
          ScrollViewer.HorizontalScrollBarVisibility="Auto">
    <DataGrid.Columns>

    <!-- 省略 -->

    <DataGridTextColumn Width="Auto"
                        Binding="{Binding PostalCode.Value}"
                        Header="郵便番号"
                        IsReadOnly="True" />

    <DataGridTextColumn Width="Auto"
                        Binding="{Binding Address}"
                        Header="住所"
                        IsReadOnly="True" />

    <DataGridTextColumn Width="Auto"
                        Binding="{Binding CompanyName}"
                        Header="会社名"
                        IsReadOnly="True" />

    <DataGridTextColumn Width="Auto"
-                       Binding="{Binding CreatedAt.Value}"
+                       Binding="{Binding CreatedAt.DisplayJapaneseEraFullFromat}"
                        Header="登録日"
                        IsReadOnly="True" />

    <!-- 省略 -->

    </DataGrid.Columns>
</DataGrid>

一旦アプリケーションを起動します。

sample-application2.png

指定したフォーマットで表示されたことが確認できました。

もし、元のフォーマットで表示したいという要求があった場合でもViewでバインディングしているプロパティを修正すれば簡単に対応可能です。

帳票で出力する際の要件では、以下のように実装すれば対応できます。

// データを取得したと仮定
var date = new List<UserEntity>();

// 帳票を出力する処理
foreach (var item in date)
{
    // 「"令和●年度"」のフォーマットで出力
    var era = item.CreatedAt.DisplayJapaneseEraFiscalYear;

    // 帳票を作成する
}

郵便番号に関する処理も同様の手順で対応できます。

最後に

今回は拡張メソッドの使い方と具体的な利用シーンについて見てきました。

チームの方針や開発現場によって利用シーンは変わってくると思いますが、一つの例として参考になれば幸いです。

参考記事