クラスと構造体は、次節で説明する「値型」か「参照型」かの違いに起因します。構造体は値型で、そのためにクラスと比べていくつか用途に制限がかかります。

目次

概要

データの構造化」で少し触れて以来、ずっとクラスだけを使って説明してきましたが、ここで、C#における「もう1つの複合型」である構造体について説明します。

根本的な差は、次項で説明する「値型」か「参照型」かの違いに起因します。構造体は値型で、そのためにクラスと比べていくつか用途に制限がかかります。

構造体の制限

とりあえず、クラスと構造体を並べて書いてみましょう。

構造体 クラス
public struct SampleStruct : InterfaceA, InterfaceB
{
    public int A { get; }
    public int B { get; }

    public SampleStruct(int a, int b) { A = a; B = b; }

    public static SampleStruct operator-(SampleStruct x)
        => new SampleStruct(-x.A, -x.B);
}

public interface InterfaceA { int A { get; } }
public interface InterfaceB { int B { get; } }
public sealed class SampleClass : BaseClass, InterfaceA, InterfaceB
{
    public int A { get; set; }
    public int B { get; set; }

    public SampleClass() { }
    public SampleClass(int a, int b) { A = a; B = b; }

    public static SampleClass operator -(SampleClass x)
        => new SampleClass(-x.A, -x.B);

    public override void X() { }

    ~SampleClass() { }
}

public class BaseClass
{
    public virtual void X() { }
}

public static class StaticClass
{
    public static string Hex(int x) => x.ToString("X");
}

単純に、クラスの方ができることは多いです。

クラスにしかできないことは以下の通り。

  • 他のクラスから派生(他のクラスを継承)する
    • 継承に関連する修飾子(abstract, sealed, virtual, overrideなど)を使えるもクラスだけ
  • 静的クラス
  • 引数なしのコンストラクターの定義
  • デストラクターの定義

一方、クラス・構造体どちらでもできることは以下のとおりです。

  • 引数なしコンストラクター、デストラクター以外のメンバー定義
  • インターフェイスの実装(複数も可)
  • (構造体自身にはstatic修飾子を付けれないものの)静的メンバーの定義自体は可能

構造体の用途

「できること」でいうと、構造体はクラスの完全下位互換で、メリットがないように見えます。構造体の利点を理解するには、次項の値型と参照型についての知識が必要になります。

おおまかに言うと、

  • クラスと構造体ではメモリの使い方が違う
  • 小さなデータ構造に対しては構造体が有利
    • 使い方にもよりますが、大まかなガイドラインとしては16バイト程度が境界と言われています

というものです。

この性質と、前節で説明した制限とを併せて考えると、構造体の利用を検討するのは、

  • データ構造が16バイト未満
  • 継承が必要ない

という状況下になります。

構造体の規定値

これも値型の性質になりますが、 クラス(newするまでメモリ領域を確保しない)と違って、 構造体は宣言した時点でデータを記録するためのメモリ領域が確保されます。

クラス型のフィールドの場合は、newするなり他のインスタンスを代入するなりして初期化するまでの間、 null (何のインスタンスも指していない状態)が入ります。

一方、構造体の場合、いわゆる「0初期化」状態になっています。 全てのメンバーに対して、0、もしくはそれに類する以下のような値が入ります。

  • 数値型(int, doubleなど)の場合は0
    • 列挙型も、0 に相当する値
  • bool 型の場合は false
  • 参照型(string、配列、クラス、デリゲートなど)やNull許容型の場合は null

これら、0初期化状態にある値を、構造体の規定値(default value)と呼びます。

using System;

struct Sample
{
    public int I;
    public double D;
    public bool B;
    public string S;
}

public class Program
{
    static Sample s;

    static void Main(string[] args)
    {
        Console.WriteLine(s.I);
        Console.WriteLine(s.D);
        Console.WriteLine(s.B);
        Console.WriteLine(s.S);
    }
}
0
0
False

引数なしコンストラクター

構造体のメンバーとして、引数なしのコンストラクターを書くことはできません。 これは、引数なしのコンストラクターを規定値(0初期化)として使うせいです。

例えば以下のコードでは、Pointクラスには引数なしのコンストラクターを定義していませんが、 new Point()という書き方で引数なしの初期化を行っています。 この場合、規定値が得られます。

using System;

struct Point
{
    public int X { get; }
    public int Y { get; }
    public Point(int x, int y) { X = x; Y = y; }
    public override string ToString() => $"({X}, {Y})";
}

public class Program
{
    static void Main(string[] args)
    {
        var p1 = new Point(); // 規定値、つまり、「XもYも0に初期化」という意味で使われる
        var p2 = new Point(10, 20);
        var p3 = default(Point); // C# 2.0 以降。p1と同じ意味


        Console.WriteLine(p1);
        Console.WriteLine(p2);
        Console.WriteLine(p3);
    }
}
(0, 0)
(10, 20)
(0, 0)
Ver. 2.0

ちなみに、C# 2.0 以降では、構造体の規定値は、new T()という書き方の他に、default(T)という書き方もできます。 (主にジェネリックのために導入された構文です。)

規定値について、詳しくは別項「規定値」で説明します。

確実な初期化

new T()default(T)で作る「規定値」とは違って、 引数付きのコンストラクターを使う場合は、コンストラクター内で全てのメンバーをきっちり自分の手で初期化する必要があります。

例えば、以下のコードは、コンストラクター内で _z の初期化を忘れているのでコンパイル エラーになります。

struct Sample
{
    int _x;
    int _y;
    int _z;

    public Sample(int x, int y)
    {
        _x = x;
        _y = y;
        // コンパイル エラー
    }
}

(クラスの場合はこういう制限はなく、明示的に初期化しなかったフィールドは規定値(0)で初期化されます。)

また、全てのフィールドを初期化するまで、プロパティやメソッドなどの関数メンバーを呼べないという制約もあります。

struct Sample
{
    int _x;
    int _y;

    public Sample(int x, int y)
    {
        M(); // エラー: _x, _y の初期化より前に呼んじゃダメ。
        _x = x;
        _y = y;
        M(); // この順ならOK。
    }

    void M() { }
}

(同じくクラスの場合は制限はなし。規定値(0)が使われるだけ。)

自動プロパティの扱い変更

Ver. 6

前節の「確実な初期化」と絡んで、C# 5.0までのC#では、自動プロパティの初期化が非常に面倒でした。

C# 5.0 以前の場合、以下のコードはコンパイル エラーを起こします。

public struct Point
{
    public int X { get; private set; }
    public int Y { get; private set; }

    public Point(int x, int y)
    {
        // C# 5.0 まではエラーになる
        X = x;
        Y = y;
    }
}

エラーを起こす原因は、以下の組み合わせのせいです。

  • 自動プロパティを定義すると、コンパイラーが対応するフィールド(バック フィールド)を作る
  • 構造体の制約のせいで、バック フィールドが初期化されるまで、プロパティの読み書きできない
  • でも、自動プロパティの場合、プロパティを介さずにバック フィールドを初期化する方法がない

このせいで、構造体と自動プロパティは相性が悪く、以下のように、自動プロパティを使わない書き方に書き換える必要がありました。

public struct Point
{
    private int _x;
    public int X { get { return _x; } }

    private int _y;
    public int Y { get { return _y; } }

    public Point(int x, int y)
    {
        _x = x;
        _y = y;
    }
}

これに対して、C# 6では、最初のコードがコンパイルできるようになっています。 C#の仕様書に以下の1文が追加されたことによります。

  • 自動プロパティを型の中から使う場合、そのバック フィールドに対する読み書きに置き換える

この仕様が追加されたことで、先ほどのコードはバック フィールドの初期化と見なされ、構造体の制約に引っかからなくなりました。

ちなみに、C# 6の場合は get のみの自動プロパティ(get-only auto-property)という構文が追加されて、先ほどのコードはさらに、以下のように書けるようになりました。

public struct Point
{
    public int X { get; }
    public int Y { get; }

    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }
}

メンバー毎コピー、メンバー毎比較

構造体の変数への代入は、全メンバーのコピーになります。 また、構造体には自動的にEqualsメソッドが実装されて、メンバー毎の比較(全メンバー一致の場合に一致)になります。

using System;

public class Program
{
    public struct Point
    {
        public int X { get; }
        public int Y { get; }

        public Point(int x, int y) { X = x; Y = y; }
        public override string ToString() => $"({X}, {Y})";
    }

    static void Main(string[] args)
    {
        var x = new Point(1, 2);
        var y = x;

        Console.WriteLine(y); // x のメンバー毎コピー = (1, 2)

        // メンバー毎比較(全メンバー一致なら一致)
        Console.WriteLine(x.Equals(new Point(1, 2))); // true
        Console.WriteLine(x.Equals(new Point(1, 3))); // false
    }
}

更新履歴

ブログ