Build Insiderオピニオン:岩永信之(16)
C# 7のタプルが一般的なガイドラインに沿わずに書き換え可能な構造体である背景
C# 7.0で登場した新しいタプル(ValueTuple構造体)は、複数の値をひとまとめにして扱うのに便利なデータ構造だが、その実装は一般的な構造体のガイドラインに従っていない。なぜそうなっているのか、技術的背景を追う。
C# 7.0のタプル機能では、内部的にValueTuple
*1という構造体を使っている。この構造体は型引数の数が0~8個のものがあるが、例えば2引数のものはリスト1のような構造になっている。
public struct ValueTuple<T1, T2>
{
public T1 Item1;
public T2 Item2;
}
|
- *1 .NET Framework 4.7がインストールされていない環境では、NuGetでSystem.ValueTupleパッケージをプロジェクトに追加する必要がある。詳しくはこちらを参照。
C#に慣れ親しんだ人からすると、この構造体に少々違和感があるかもしれない。フィールドがpublic
で、書き換え可能(mutable)になっている。ガイドライン的に避けるべきとされてきた構造である。一般には以下のことが推奨されている。
- (構造体に限らず)単純なデータの読み書きであってもプロパティにするべき
- 構造体は書き換え不能(immutable)に作るべき
先にいっておくと、別にガイドラインが変わったわけではなく、一般論としてはいまだこのガイドラインが正しい。しかし、C#の進歩によって問題が緩和された面もある。また、少ないながらガイドラインに沿わない方がよい場面もあり、ValueTuple
構造体はまさにその例となる。
本稿では、このガイドラインや、ValueTuple
構造体がこの構造を採用した背景について説明していく。
参照戻り値/参照ローカル変数
C# 7.0でタプルと同時に実装された機能として、参照戻り値(ref returns)と参照ローカル変数(ref locals)という機能がある。この機能の導入によって、構造体の書き換えに関する事情が変わった。
まず、参照戻り値の導入以前に、書き換え可能な構造体がどういう問題を起こしていたかを見てみよう。リスト2に例を示す。
// mutableな構造体
struct Point
{
public int X { get; set; }
public int Y { get; set; }
public Point(int x, int y) => (X, Y) = (x, y);
}
struct Polygon
{
private Point[] _vertices;
public Polygon(params Point[] vertices) => _vertices = vertices;
// mutableな構造体をプロパティ/インデクサー越しに読み書き
public Point this[int index]
{
get => _vertices[index];
set => _vertices[index] = value;
}
}
class Program
{
static void Main()
{
var p = new Polygon(
new Point(0, 1),
new Point(1, 1),
new Point(1, 0)
);
// なぜかコンパイルエラー(もともとそういう仕様)
p[0].X = 2;
// なぜかp[0].Xが書き換わらない(これも仕様)
var v0 = p[0];
v0.X = 2;
// p[0]が返す値も、v0で受け取った値もコピーなので、元の値が書き換わらなくて当然
}
}
|
この例ではまず、以下のような2つの型が用意されている。
Point
: 2つの整数値X
とY
を持つ書き換え可能な構造体Polygon
: 何点かのPoint
構造体のインスタンスを持っていて、それをインデクサー越しに返す構造体
問題となるのは、Main
メソッド内でやっているように、Polygon
のインデクサーを通してPoint
のX
プロパティとY
プロパティを書き換えようとしたときである。連載第7回でも触れたが、プロパティやインデクサーを介して構造体の値を返すとコピーが発生する。上記コードで書き換えようとしているのはコピーであって、元の値は書き換わらない。
クラス(参照型)と構造体(値型)の違いをあまりはっきりと把握できていない開発者もいるし、たとえしっかり分かっていてもこの例のようなミス(書き換えたつもりが書き換わっていない)はやりがちである。コピーを書き換えられてしまうことで起きる問題なので、構造体は書き換えられないように作る方が、事故が減ってよいとされる。
一方で、参照戻り値を使えば、リスト3に示すように、コピーは作られず、Polygon
内で持っているPoint
を書き換えられる。
// Pointは元と一緒なので省略
struct Polygon
{
private Point[] _vertices;
public Polygon(params Point[] vertices) => _vertices = vertices;
// 構造体をmutableにしたいときというのは、大体パフォーマンス優先のとき
// そういうときにはref returnsが使える
public ref Point this[int index] => ref _vertices[index];
}
class Program
{
static void Main()
{
var p = new Polygon(
new Point(0, 1),
new Point(1, 1),
new Point(1, 0)
);
// ちゃんと書き換わる
p[0].X = 2;
// ちゃんとp[0].Xが書き換わる
ref var v0 = ref p[0];
v0.X = 2;
}
}
|
もちろんこれは、「書き換え可能な構造体が許容されるようになった」ということであって、「書き換え可能であることを推奨する」というものではない。実際のところ、構造体を書き換え可能(mutable)に作るべきか不能(immutable)に作るべきかは状況次第である。プロパティやインデクサーも、書き換え可能な構造体に対しては参照戻り値を使用し、書き換え不能な構造体に対しては通常のget
/set
を使用するというように使い分けるべきかもしれない。
readonly
ちなみに、書き換え可能に作った構造体であっても、readonly
修飾子を付けることで書き換え不能にできる。例えばリスト4のように、readonly
なフィールドのメンバーを書き換えようとするとコンパイルエラーになる。
struct Point
{
public int X { get; set; } // プロパティ
public int Y; // こっちはフィールド
public Point(int x, int y) => (X, Y) = (x, y);
}
class Program
{
static readonly Point XUnit = new Point(1, 0);
static readonly Point YUnit = new Point(0, 1);
static void Main()
{
XUnit.Y = 1; // ちゃんとコンパイルエラー
YUnit.X = 1; // プロパティのsetも呼べない
}
}
|
もし、構造体を書き換え可能に作ることにしたなら、この仕様も活用するといいだろう。
実装の隠ぺい
オブジェクト指向的な技法の1つに、「実装の詳細は隠せ」というものがある。public
な部分さえ変えなければ実装方法を変えても利用側に影響が出ないことから、変更に強いプログラムが書ける。
例えばリスト5のようなコードを考えてみる。2つの構造体があるが、いずれも「単位円周の点の座標」を表す構造体で、public
な部分(コンストラクターと、X
/Y
プロパティ)は全く同じである。一方で、内部的には、Circular1
ではX
/Y
それぞれの値を持っていて、Circular2
では角度の値を1つだけ持っている。
using System;
// 単位円周上の点
struct Circular1
{
public double X { get; }
public double Y { get; }
public Circular1(double x, double y)
{
var abs = Math.Sqrt(x * x + y * y);
X = x / abs;
Y = y / abs;
}
}
// 単位円周上の点なら、角度でデータを持てばdouble 1個で済む
// 計算誤差を除けばCircular1と全く同じ挙動を、半分のメモリ消費で実現している
struct Circular2
{
private double _angle;
public Circular2(double angle)
{
if (angle == 0) angle = 2 * Math.PI; // default値の挙動をCircular1と合わせるため
_angle = angle;
}
public Circular2(double x, double y) : this(Math.Atan2(y, x)) { }
public double X => _angle == 0 ? 0 : Math.Cos(_angle);
public double Y => _angle == 0 ? 0 : Math.Sin(_angle);
}
|
例えば最初はCircular1
のような実装をしていたとしよう。しかし、途中で、メモリ消費量の削減が最重要課題になったため、Circular2
のような実装への変更が必要になったとする。このとき、public
な部分は変えていないので、すでにこの構造体を使っている箇所があったとしても、何も影響も出さずに構造体の中身を書き換えることができる。
こういうメリットがあるため、一般論としては、フィールドをpublic
にするべきではない。「外から見える挙動」が大事という意味で、オブジェクト指向的な考え方は「振る舞い(behavior)が主役」といわれる。
データが主役の設計
これに対して、「データそのものが主役」な使い方もあるだろう。どういうデータを持っているかということ自体が仕様であって、そこを変更するということは利用側に影響が出てしかるべき場面は普通に多い。実装を隠せといわれても、読み書き両方できるpublic
なプロパティしかないような型を書くことも多いだろう。特に、通信やデータの保存が絡む場面ではよくある。
「2次元上の点のX座標とY座標を、それぞれdouble
値で保存・送受信する」といわれれば、リスト6のようなデータ構造そのものが仕様になるだろう。
public struct Point
{
public int X;
public int Y;
}
|
ValueTuple
構造体はまさに「データが主役」な用途で使うものである。実装の詳細を隠す意味はあまりない。
こういう類いの型では、別にフィールドがpublic
であってもプロパティと大差ないだろう。一応、クラスであれば、リスト7に示すように、virtual
にできることでプロパティの意義が出てくる。
using System.ComponentModel;
// クラスの場合はvirtualにすることでプロパティにする意味もある
public class Point
{
public virtual int X { get; set; }
public virtual int Y { get; set; }
}
// 例えば、既存の型に対して変更トラッキング機能を追加したい場面がある
internal class PointImpl : Point, INotifyPropertyChanging, INotifyPropertyChanged
{
public override int X
{
get => base.X;
set
{
if (base.X != value)
{
PropertyChanging?.Invoke(this, new PropertyChangingEventArgs(nameof(X)));
base.X = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(X)));
}
}
}
// Xと同様なのでYは省略
public event PropertyChangingEventHandler PropertyChanging;
public event PropertyChangedEventHandler PropertyChanged;
}
|
しかし構造体の場合は継承できないため、このような利点も得られない。データが主役、かつ、書き換え可能な構造体では、フィールドを直接public
にして起こる不利益はないだろう。
メモリ上のレイアウト
前節の「どういうデータを持っているか」ということ自体が仕様という話をさらに一歩進めて、「どういうデータをメモリ上にどういうレイアウトで持つか」まで含めて仕様としたい場合がある。主に、高パフォーマンスを求められる分野で必要になる。
例えばリスト8のような構造体を考える。これは、8bytesのデータを1byteずつ読み書きする構造体である。
public struct Vector
{
public byte A;
public byte B;
public byte C;
public byte D;
public byte E;
public byte F;
public byte G;
public byte H;
}
|
C#では、こういう書き方をするとA
~H
の各フィールドがこの順で、隙間なく並ぶことが保証されている。Vector
のサイズは全体で8bytesになり、最初の1バイト目がA
フィールド、その次がB
フィールド、……となり、最後の8バイト目がH
フィールドである。
A
~H
がこのレイアウトで並んでいるという前提の上でできる最適化がある。例えばこの構造体に対して、メンバーごとの足し算をしたいとする。レイアウトに関する前提がなければ、リスト9のような実装になるだろう。
public static Vector operator +(Vector x, Vector y)
=> new Vector(
(byte)(x.A + y.A),
(byte)(x.B + y.B),
(byte)(x.C + y.C),
(byte)(x.D + y.D),
(byte)(x.E + y.E),
(byte)(x.F + y.F),
(byte)(x.G + y.G),
(byte)(x.H + y.H));
|
一方で、レイアウトが決まっているなら、リスト10に示すように、ulong
(64bit整数)扱いして計算するような実装ができる。例えばx64 CPUではリスト9のコードよりもこちらの方が2~3倍程度高速である。
static ulong evenMask = 0x00ff00ff00ff00ff;
static ulong oddMask = 0xff00ff00ff00ff00;
public unsafe static Vector operator +(Vector x, Vector y)
{
var lx = *(ulong*)(&x);
var ly = *(ulong*)(&y);
var lz = evenMask & ((evenMask & lx) + (evenMask & ly))
| oddMask & ((oddMask & lx) + (oddMask & ly));
return *(Vector*)(&lz);
}
|
こういう実装で正しく動作するかや、高速化できるかはCPUの種類にもよるため、扱いに注意を要するコードではある。しかし、この2~3倍の速度が必要とされる場面は確実にある。
そこでレイアウトも含めて固定したいわけだが、レイアウトを決めているのはフィールドの宣言順である。この例の場合はA
~H
というフィールドがこの順で並んでいる必要がある。そして、これを「仕様」と考えると、フィールドはpublic
(=誰からでも見え、変更は利用側に影響が出る)な方が好ましい可能性すらある。
一般的とは言いにくいが、public
なものであってもプロパティよりもフィールドの方が好ましい場合もあり得るわけである。そして、高パフォーマンスを求めるほどこういう場面が増える。連載第6回で説明した通り、C#でも高パフォーマンスを求められる用途が増えているため、フィールドをpublic
にすることが多くなるかもしれない。
型変換と参照戻り値
リスト10の例では、レイアウトの固定を前提として、全く異なる型の間での変換を行っている。こういう処理は、これまでであればポインターが必須だった。しかし、これも、参照戻り値の登場によって、直接のポインター利用を避けられるようになった。リスト11のような書き方ができる(実行にはSystem.Runtime.CompilerServices.Unsafeパッケージの参照が必要)。
using static System.Console;
using System.Runtime.CompilerServices;
class Program
{
static void Main()
{
var v = new Vector { A = 1, B = 2, C = 3, D = 4, E = 5, F = 6, G = 7, H = 8 };
// これまでも書けたが、ポインターが必要だった
unsafe
{
long* p = (long*)(void*)&v;
WriteLine(p->ToString("X"));
}
// 参照戻り値があれば、ポインターなしで同様のことができる
// ここにはunsafe宣言が不要
ref long r = ref AsLong(ref v);
WriteLine(r.ToString("X"));
}
// Unsafeクラスを利用
// これ自身は、クラス名の通りunsafeだし、ポインターを介する
unsafe static ref long AsLong(ref Vector v)
=> ref Unsafe.AsRef<long>(Unsafe.AsPointer(ref v));
}
|
Unsafeクラスを使うには、System.Runtime.CompilerServices.Unsafeパッケージをインストールする必要がある。
直接ポインターを使っていたものが、間接的利用に変わっただけなので、この書き方で安全になるわけではない。しかし、&
、*
や->
などの、煩雑な記法は減り、コードをすっきりさせることができる。
中身による等値比較
C# 7.0のタプルと似た機能として、C# 3.0の匿名型がある。型名を持たない型という意味では同じだが、用途の違いから、実装に差がある。また、タプルが内部的に使っているValueTuple
構造体にも、類似のTuple
クラス(System
名前空間)というものが存在する。そして、匿名型もTuple
クラスも、書き換え不能(immutable)である。にもかかわらず、タプル(そしてValueTuple
構造体も)は書き換え可能なことを不思議に思うかもしれない。
結論からいってしまえば、タプルとValueTuple
は構造体(値型)だから書き換えられても問題がなく、匿名型とTuple
はクラス(参照型)だから書き換えが問題となる。前述の参照戻り値の話では値型だとコピーが発生することで問題が生じていたが、逆にコピーされるからこそ書き換えても問題ないという場面も存在するのである。
具体的にいうと、中身の値を見て等値比較するようなクラスは書き換え不能でなければならない。例えばリスト12のようなクラスがそうである。Equals
メソッドとGetHashCode
メソッドを、プロパティX
とY
の値を見て等値判定するようにオーバーロードしている*1。ちなみに、起こりうる問題を説明するために、あえて書き換えできるように作ってある。
- *1 通常であれば、参照型のインスタンスはどこを参照しているかによって等値判定を行う。中身が同じであっても、同じ場所を参照していなければ等しいとは見なされない。
using System;
class MutableClass : IEquatable<MutableClass>
{
public int X { get; set; }
public int Y { get; set; }
public bool Equals(MutableClass other) => X == other.X && Y == other.Y;
public override bool Equals(object obj) => obj is MutableClass other &&Equals(other);
public override int GetHashCode() => X.GetHashCode() * 1234567 + Y.GetHashCode();
}
|
参照型であるため、このクラスのインスタンスは複数の場所で共有される場合がある。その結果、どこか自分が把握していない場所で他者に書き換えられることがある。参照型のこの性質は、中身による等値比較と相性が悪い。「等しいと思っていたものが、気が付けば等しくなくなっていた」ということがあり得るわけだが、これが大きな問題となる。
この問題として特に有名なのは、ハッシュテーブルのキーとして使う場合である。リスト13の例を見てほしい(Dictionary
クラスの内部実装はハッシュテーブルである)。リスト12のMutableClass
クラスをキーにしていることで、テーブル内の検索や削除が不可能な状況に陥る。
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
var map = new Dictionary<MutableClass, int>();
var key1 = new MutableClass { X = 1, Y = 2 };
map[key1] = 1;
// キーにしたインスタンスを書き換えてしまう
key1.X = 2;
// 当然、見つからなくなる
Console.WriteLine(map.TryGetValue(key1, out _)); // False
// 元のキーと同じ値の、別インスタンスを作る
var key2 = new MutableClass { X = 1, Y = 2 };
// これでも、見つからなくなる
Console.WriteLine(map.TryGetValue(key2, out _)); // False
}
}
|
ハッシュテーブルは名前通り、キーのハッシュ値によって値の格納場所を決定している。この例では最初に{ X=1, Y=2 }
をキーにしてテーブルに値を格納しているわけだが、この時点で{ X=1, Y=2 }
から計算したハッシュ値を使っている。ところが、キーとして渡したインスタンスを書き換えたことで、{ X=1, Y=2 }
が入っているべき場所に{ X=2, Y=2 }
が入っている状態に陥る。{ X=2, Y=2 }
のハッシュ値ではたどり着けないし、{ X=1, Y=2 }
のハッシュ値を使ってたどりついても、肝心の中身がEquals
では一致しないため、検索不能になる。
一方で、キーが構造体であれば、この問題は起きない。リスト14のコードは、クラスから構造体に変更した以外はリスト13のコードと同等だが、問題は解消されている。コピーが起きることによって、書き換えてもハッシュテーブルに格納したインスタンスには影響を及ぼさないからである。
using System;
using System.Collections.Generic;
struct MutableStruct : IEquatable<MutableStruct>
{
public int X { get; set; }
public int Y { get; set; }
public bool Equals(MutableStruct other) => X == other.X && Y == other.Y;
public override bool Equals(object obj) => obj is MutableStruct other && base.Equals(other);
public override int GetHashCode() => X.GetHashCode() * 1234567 + Y.GetHashCode();
}
class Program
{
static void Main()
{
var map = new Dictionary<MutableStruct, int>();
var key1 = new MutableStruct { X = 1, Y = 2 };
map[key1] = 1;
// 構造体の場合、Dictionaryにはコピーが渡るので、こっちを書き換えてもmapには影響なし
key1.X = 2;
// 当然、key1では見つけられない
Console.WriteLine(map.TryGetValue(key1, out _)); // False
// 元のキーと同じ値を作る
var key2 = new MutableStruct { X = 1, Y = 2 };
// 見つかる!
Console.WriteLine(map.TryGetValue(key2, out _)); // True
}
}
|
参照型である匿名型やTuple
クラスは書き換えられてはまずいが、値型であるタプル、ValueTuple
構造体は書き換え可能でも問題ない。
タプルの用途
これまでの説明はおおむね、タプルが「書き換え可能でもよい」「フィールドをpublic
にしてもよい」という話である。実際にそうすべきかどうかはタプルの用途を見て考えるべきだろう。
タプルは複数の変数、フィールドや戻り値などを束ねる用途に使われる。そのため、タプルの要素はそれら変数などと同様の書き心地であることが好ましいだろう。その結果が、書き換え可能で、フィールドがpublic
な構造である。
例えば、タプルは、図1に示すように変数や引数と、図2に示すようにフィールドと、「t.
」というような接頭辞が増える以外は同じ書き方ができる。
ちなみに、図1の例ではref
やout
などを使っているが、C# 7.0で参照戻り値などの機能が入ったことで参照(ref
やout
)を使う機会は増えると思われる。タプルの要素を参照として渡すにはプロパティではなく、フィールドでなければならない。
また、図2の例ではフィールドを直接持たず、タプルとしてまとめているが、これは厳密な型付けと緩い型付けの橋渡しに使える。例えば、リスト15~リスト17では、同じ「int
型の値X
とY
を持つ型」だが、書き換えの可否、値型か参照型か、単に値を持つかさらに追加機能を持つかなどの用途の差で別の型となっている。
struct Point
{
public readonly (int X, int Y) Value;
public int X => Value.X;
public int Y => Value.Y;
public Point(int x, int y) => Value = (x, y);
public Point((int X, int Y) value) => Value = value;
}
|
class Point
{
public (int X, int Y) Value;
public int X { get => Value.X; set => Value.X = value; }
public int Y { get => Value.Y; set => Value.Y = value; }
}
|
class Point : BindableBase
{
public (int X, int Y) Value;
public int X { get => Value.X; set => SetProperty(ref Value.X, value); }
public int Y { get => Value.Y; set => SetProperty(ref Value.Y, value); }
}
|
リスト15は書き換え不能な構造体、リスト16は書き換え可能で単なる値を持つだけのクラス、リスト17は変更通知付きのクラスである。これらのクラスは、Value
というタプル型のフィールドを介して相互にデータの受け渡しができる。また、タプルを書き換え不能にしたければ、リスト15の例のようにreadonly
修飾子を付ければよい。
まとめ
ValueTuple
構造体が書き換え可能で、public
なフィールドを持つ背景について、以下のような話をしてきた。
- C# 7.0の参照戻り値によって、構造体が書き換え可能でも問題が起こりにくくなった
- データが主役のデータ構造ではメンバーをプロパティにする動機は弱い
- 参照戻り値や参照ローカル変数などとの相性を考えるとフィールドの方が好都合
- 高パフォーマンスを求める場面なので、フィールドの並び自体に意味がある場合がある
- 等値比較などの面では、構造体でこそ書き換え可能で構わない(クラスの場合に書き換えがまずい)場面もある
一見すると親しまれたガイドラインに反しているようでも、必要があってのことである。ガイドラインをうのみにするのではなく、ガイドラインがある理由や、時代による変化、ガイドラインに当てはめられない例をきっちり把握し、状況に応じた使い分けをしていきたい。
岩永 信之(いわなが のぶゆき)
※以下では、本稿の前後を合わせて5回分(第13回~第17回)のみ表示しています。
連載の全タイトルを参照するには、[この記事の連載 INDEX]を参照してください。
13. インターフェースを「契約」として見たときの問題点 ― C#への「インターフェースのデフォルト実装」の導入(前編)
C#におけるインターフェースとは、ある型が持つべきメソッドを示す「契約」であり、実装は持てない。だが、このことが大きな問題となりつつある。今回から全3回に分けて、C#がこの問題にどう対処しようとしているかを見ていく。
14. デフォルト実装の導入がもたらす影響 ― C#への「インターフェースのデフォルト実装」の導入(中編)
前回は一般論としてのインターフェースとその課題を見た。今回はC#にインターフェースのデフォルト実装を導入すると、どのようなコードが書けるようになるのか、導入するために必要な修正点などについて見ていく。
15. インターフェースを拡張する2つの手段 ― C#への「インターフェースのデフォルト実装」の導入(後編)
破壊的な影響を他に及ぼすことなくインターフェースの機能を拡張するには、デフォルト実装に加えて拡張メソッドも使用できる。今回はこれら2つの方法がなぜ必要なのか、それぞれが得意としている分野について詳しく見る。
16. 【現在、表示中】≫ C# 7のタプルが一般的なガイドラインに沿わずに書き換え可能な構造体である背景
C# 7.0で登場した新しいタプル(ValueTuple構造体)は、複数の値をひとまとめにして扱うのに便利なデータ構造だが、その実装は一般的な構造体のガイドラインに従っていない。なぜそうなっているのか、技術的背景を追う。
17. C# 7.1: 半年でのマイナーリリース
C# 7で始まったリリースサイクルの短期化に伴って、つい先日C# 7.1がリリースされた。そこに含まれる新機能、C# 7.2/7.3に含まれる予定の機能、そこから見えてくるものについて考えてみよう。