【C#】ジェネリックの基礎 | チュートリアル

C#のプログラム言語にはジェネリックという強力な機能が備わっており、これを一般的に「総称化」と呼んでいます。これはC#に限った話ではなくJavaなどのオブジェクト指向言語に備わっている機能です。

ジェネリックには「一般的な、包括的な、汎用的な、総称的な」という意味がありますが、その名の通り「一般的」にしてより汎用的にメソッドなどを使えるようにしよう、という意味があると思っています。実際のところ、ジェネリックはコンパイラによって型が保証されるため、よりタイプセーフです。

この記事では、そんなジェネリックの基礎知識を知るために基本的な事柄について記載しました。ジェネリックをうまく使って、より安全なコーディングができることを目指していきましょう。

ジェネリックの基礎知識

まずはジェネリックの基礎知識について簡単に解説します。ジェネリックは C#2.0 で登場した機能です。様々な型に対応した処理をするために、パラメーターとして型を与えて、その型に対応した処理を生成することのできる機能です。

本来は特定の型に依存した状態でメソッド等を記述していくのが一般的ですが、それらの「前提」をパラメーターとして渡すことで、特定の処理に依存せずに記述することができるものです。

ジェネリックを使うことで得られる利点は以下が挙げられます。

  • 型の安全性
  • コード記述の簡易化・共通化
  • 処理の高速化

ジェネリックメソッドを使用するには、引数の数やデータ型、戻り値の型を指定して定義します。

定義と異なる呼び出し方をするとコンパイルエラーになるため、型の安全性(タイプセーフ)を実現することができます。型が異なっているが、処理内容が共通化しているような処理を抽象化し、呼び出し元から型の条件などを指定して使うことができます。また、これらはメソッドだけでなくクラスにまで適用することが可能となります。

ジェネリックメソッドのサンプル

ジェネリックの機能を使用したメソッドの使い方を解説していきます。ジェネリックは「型が異なるが処理が同じもの」を作るときにとても便利な機能になります。「異なる型」をまとめる方法はどうすればよいのでしょうか。

一般的なサンプルとして紹介されるのが、二つの引数に対して大きいほうの値を返すメソッドになります。例えば int 型と double 型を使用して通常のメソッドを記述すると以下のようになります。

//App01
int a1 = 5, b1 = 6;
var ret1 = IntMax(a1, b1);
Console.WriteLine(ret1.ToString());

double a2 = 10, b2 = 13;
var ret2 = DoubleMax(a2, b2);
Console.WriteLine(ret2.ToString());

Console.ReadLine();

static int IntMax(int a, int b)
{
    return a >= b ? a : b;
}

static double DoubleMax(double a, double b)
{
    return a >= b ? a : b;
}

IntMax メソッドも DoubleMax メソッドも同じ処理をしています。通常では型が異なるため別メソッドで切り出す必要があります。こうした処理は一般化できたらコード行数を少なくすることができます。こういった場合にジェネリックを使用します。

//App02
int ret1 = Max<int>(10, 100);
Console.WriteLine(ret1.ToString());

double a = 0.1, b = 0.15;
double ret2 = Max(a, b);
Console.WriteLine(ret2.ToString());

float a1 = 10, b1 = 15;
var ret3 = Max<float>(a1, b1);
Console.WriteLine(ret3.ToString());

Console.ReadLine();

//ジェネリックメソッド
static T Max<T>(T a, T b) where T : IComparable<T>
{
    return a.CompareTo(b) >= 0 ? a : b;
}

上記のサンプルではジェネリックメソッドを以下のように定義しています。

static T Max<T>(T a, T b) where T : IComparable<T>
{
    return a.CompareTo(b) >= 0 ? a : b;
}

上記のメソッド1個で int, double, float の処理をカバーできているのがすごいですよね。このようにジェネリックメソッドを使用することで、処理は同じだけど型が異なる場合も共通化することができるのです。

ジェネリックメソッドの作成方法

前項のサンプルではジェネリックメソッドを使うことで、型が異なる場合だけの処理を共通化できることがわかりました。この機能を使うことで処理ロジックを抽象化でき、呼び出し元で型を指定して処理を行うことができるようになります。

というわけでジェネリックメソッドを作成する方法を、まずはサンプルを使いながら解説していきます。

//ジェネリックメソッド
static T Max<T>(T a, T b) where T : IComparable
{
    return a.CompareTo(b) >= 0 ? a : b;
}

ジェネリックメソッドの定義

まずはジェネリックメソッドの定義を簡単に解説します。ジェネリックメソッドは以下のように定義することで使用可能となります。

アクセス修飾子 戻り値の型 メソッド名<型引数>()
{
    //処理
}

アクセス修飾子は public や private などですね。前回のサンプルでは static が使われていました。戻り値の型はメソッドの戻り値になります。メソッド名のあとに 「<>」を使用して、その間に型引数を記載します。

先ほどのサンプルでは T がその役目を務めていました。サンプルでは T の型引数に対して、戻り値が同じ型だったため T を使用していますね。もちろん引数も呼び出し元で変わるので T を使用していました。ジェネリックでは、一般的に T が用いられることが多いようです。

型引数に制約を加える

ジェネリックメソッドを使用する場合、型引数を共通化することができるので便利ですが、どんな型でもOKとしてしまうのは不安です。それを防ぐため型引数に制約を加えることができます。

サンプルではメソッド名の後に「where T : IComparable」という記載がありますが、この部分が制約を加えている部分になります。「T の型引数の条件として IComparable が実装されている型であること」を条件としています。

where 句を使用することでジェネリック型の型引数として使用される型が、指定されたクラスを基底クラスとして持っているか、または基底クラスであることを明示的に示すことができます。マイクロソフトのサンプルでは以下のようなものも挙げられていました。

public class UsingEnum<T> where T : System.Enum { }
public class UsingDelegate<T> where T : System.Delegate { }
public class Multicaster<T> where T : System.MulticastDelegate { }

制約として加えられるものには様々なものがあり、クラスやインターフェースだけではありません。また複数の制約を加える場合は「,」を使うことで追加することが可能です。「Object、Array、ValueType」といった方は制約として許可されないので注意しましょう。

ジェネリッククラスの使い方

ジェネリックメソッドを解説してきましたが、ジェネリックではクラスに対しても適用することが可能です。基本的にはジェネリックメソッドと同じになります。まずは定義を確認してからサンプルを見ていきましょう。

アクセス修飾子 class クラス名<型引数>
{
    //クラスの記述
}

ジェネリッククラスを作成する場合は上記のように定義することで作成することが可能となります。簡単なサンプルを作成してみました。基本的にはジェネリックメソッドと同じであることがわかるかと思います。

//App03
var c1 = new SampleClass<int>();
var ret1 = c1.Max(1, 10);
Console.WriteLine(ret1.ToString());

var c2 = new SampleClass<double>();
var ret2 = c2.Max(0.8, 0.9);
Console.WriteLine(ret2.ToString());

var c3 = new SampleClass<float>();
var ret3 = c3.Max(3, 2);
Console.WriteLine(ret3.ToString());

Console.ReadLine();

public class SampleClass<T> where T : IComparable
{
    public T Max(T a, T b)
    {
        return a.CompareTo(b) >= 0 ? a : b;
    }
}

上記のサンプルは使用方法も記載しています。サンプルクラスとして定義しているのは以下の部分だけになります。

public class SampleClass<T> where T : IComparable
{
    public T Max(T a, T b)
    {
        return a.CompareTo(b) >= 0 ? a : b;
    }
}

ジェネリッククラスではメソッドと同じように、クラスの後に型引数を記載します。サンプルでは「」の部分になります。また型引数に対して制約を加える場合に where T : ~ の部分も同じであることがわかりますね。

今回のクラスでも、ジェネリックメソッドで定義したものと同様に「IComparable を実装している型のみを対象とする」という制約を加えています。このように制約を加えることで T に対する処理の前提を付け加えることができます。

ジェネリッククラスをインスタンス化する場合は、以下のようにすることでオブジェクトを生成できます。

var c1 = new SampleClass<int>();
var c2 = new SampleClass<double>();
var c3 = new SampleClass<float>();

クラスをインスタンス化する際に型を指定してクラスをインスタンス化することができます。「SampleClassにおける T の型引数は int(double, float) として扱います」ということを宣言しているわけですね。

型制約を加える必要性

これまでジェネリックメソッドとジェネリッククラスを紹介してきました。「なぜ型引数に対して制約を付け加える必要があるのか?と考える人も多いかと思います。ここでは、その「なぜ」につい考えてみたいと思います。

型に対して制約を加えるということは「型引数の前提条件」を作ることができるので重要になります。

ジェネリックメソッドやジェネリッククラスでは「where T : IComparable」を多用してきたので、まずはその例で考えてみます。IComparable のインターフェースの定義を確認してみると、以下のように記載されています(説明文などは削除して簡略化しています)。

namespace System
{
    public interface IComparable
    {
        int CompareTo(object? obj);
    }
}

IComparable インターフェース には CompareTo メソッドが定義されています。インターフェースは「契約」であるため、IComapable が定義されているということは「CompareTo メソッドが必ず実装されている」という前提になります(コンポジション)。

static T Max<T>(T a, T b) where T : IComparable
{
    return a.CompareTo(b) >= 0 ? a : b;
}

上記のようにジェネリックメソッドを定義しており、制約として IComparable を課しているというのは、逆に言うと「CompareTo メソッドが必ず定義されている」ということであり「どんな引数が来ても CompareTo メソッドは使用できる」ということを示しています。

この前提があるため、int, double, float などを型引数として与えたクラスやメソッドは CompareTo メソッドを軸に処理を組み立てることができるということに他ならないのです。

static T MinusMethod<T>(T x, T y)
{
    return x - y;
}

例えば上記のようにメソッドを定義しているとしましょう。

型引数に対する制約がない場合、T x と T y は、演算子「-」に対応するメソッドが定義されているかわかりせん。演算子に対応する処理を持っていない型も存在するからです。

こういった制約のない条件での定義は不具合を生む可能性もありますし、メソッドやクラスとして共通項を持たないため煩雑な共通化しかできなくなります。このような不安定な前提ではメソッドを使用するのは怖いですよね。

このような事態を防ぐためにも制約が必要で、制約によって「型の安全性」「タイプセーフ」が保たれるのです。最低限の前提がしっかり定義されていることで、安全な共通化をおこなうことができるということになります。

型制約の種類

型制約でよく使われる代表的なサンプルを一覧で紹介します。以下の型制約を知っておくだけでも、ジェネリックを使用する分には十分だと思います。しっかりと押さえておきましょう。

定義意味
where T : struct値型のみ
where T : class参照型のみ
where T : クラス名指定したクラス、もしくは派生クラス
where T : インターフェース名指定したインターフェースを持つクラス

以上でジェネリックの基礎的な解説が完了です。ジェネリックは「型が異なるが処理内容が同じであるもの」を共通化することができる便利な機能であるため、知っておいて損のない記述方法です。

単純にコーディング作業を行う上ではあまり使うことはないかもしれませんが、共通的なAPIを作成するような立場になった場合、型制約などを考慮しながらメソッドやクラスを定義していくスキルは必須になるはずです。

ジェネリックは単なるプログラミングから考えるとより抽象度の高いコーディング作業になりますが、共通化することでコード数を抑えることができ、かつ型制約などで強固で安全性の高い設計を行うことができるようになるのです。