概要
前項では、C# 7.2 の新機能と深くかかわる Span<T>
構造体という型を紹介しました。
この型は、論理的には (ref T Reference, int Length)
というような、「参照フィールド」と長さのペアを持つ構造体です。
「参照」を持っているので、参照戻り値や参照ローカル変数と同種の「出所の保証」が必要です。
またSpan<T>
には「スタック上に置かれている必要がある」(ヒープに置けない)という制限が必要です。
さらに、Span<T>
に制限か掛かっている以上、「Span<T>
を持つ型」にも再帰的に制限が掛かります。
「Span<T>
を持つか持たないか」だけで挙動が変わるのでは影響範囲が大きすぎるため、
「Span<T>
を持ちたければ ref
という修飾が必要」という制約もあります。
ここでは、これらの Span<T>
の「スタック上に置かれている必要がある」という制約や、「ref
構造体」について説明していきます。
(ref
構造体という機能ではありますが、主用途がSpan<T>
に関するものなので、span safety ruleと呼ばれたりもします。)
ref 構造体
Span<T>
には制限が必要といっても、C# コンパイラーとしては Span<T>
だけを特別扱いしたくはありません。
そこで、ref
構造体 (ref struct
)というものを導入しました。
ref
構造体は、名前通り、ref
修飾子が付いた構造体です。
Span<T>
構造体自身にも ref
修飾子がついています。
そして、ref
構造体をフィールドとして持てるのはref
構造体だけです。
// Span<T> は ref 構造体になっている
public readonly ref struct Span<T> { ... }
// ref 構造体を持てるのは ref 構造体だけ
ref struct RefStruct
{
private Span<int> _span; //OK
}
逆に言うと、ref
修飾子がついていない構造体や、クラスはref
構造体をフィールドとして持てません。
// NG。構造体以外を「ref 型」にはできない
ref class InvalidClass { }
// ref がついていない普通の構造体は ref 構造体を持てない
struct NonRefStruct
{
private Span<int> _span; //NG
}
そして、以下で説明する制約は、Span<T>
構造体だけでなく、すべての ref
構造体に対して掛かります。
戻り値で返せるもの
ref
構造体を戻り値として使いたい場合、
ref
戻り値・ref
ローカル変数と同様に、大元をたどって調べて(フロー解析して)、返していいものかどうかを判定します。
以下のようなルールがあります(ref
戻り値と同じルールです)。
- 引数で受け取ったものは戻り値に返せます
- ローカルで確保したものは返せません
- 引数などを介して多段に参照している場合、コードをたどって大元が安全かまで調べます
// 引数で受け取ったものは戻り値で返せる
private static Span<int> Success(Span<int> x) => x;
// ローカルで確保したもの変数はダメ
private static Span<int> Error()
{
Span<int> x = stackalloc int[1];
return x;
}
// 多段の場合も元をたどって出所を調べてくれる
private static Span<int> Success(Span<int> x, Span<int> y)
{
var r1 = x;
var r2 = y;
var r3 = r1.Length >= r2.Length ? r1 : r2;
// r3 は出所をたどると引数の x か y
// x も y も引数なので大丈夫
return r3;
}
private static Span<int> Error(Span<int> x, int n)
{
var r1 = x;
Span<int> r2 = stackalloc int[n];
var r3 = r1.Length >= r2.Length ? r1 : r2;
// r2 がローカルなのでダメ
return r3;
}
ちなみに、上記のError
と似たようなコードでも、以下のコードはコンパイルできます。
ちゃんと「メモリ確保があったかどうか」を見ていて、「default
であれば何も確保していない」という判定もしています。
// ちゃんと「メモリ確保」があったかどうかを見てる
// 同じようなコードでもこれは OK (default だと何も確保しない)
private static Span<int> Success1()
{
Span<int> x = default;
return x;
}
このルールは、ref
構造体と、ref
引数・ref
戻り値の間でも働きます。
例えば、引数由来の Span<T>
から得たref T
な戻り値にできます、ローカル由来のものはできません。
// 引数で受け取った Span 由来の ref 戻り値は返せる
private static ref int Success(Span<int> x) => ref x[0];
// ローカルで確保した Span 由来の ref 戻り値はダメ
private static ref int Error()
{
Span<int> x = stackalloc int[1];
return ref x[0];
}
readonly ref
C# 7.2 で追加された構造体がらみの修飾子にはreadonly
というものもあります。
readonly
修飾は、一見、参照がらみの機能とは無関係に見えますが、実はこれも「参照として返せるかどうか」の判定に関係しています。
例えば以下のコードを見てください。
using System;
// ref だけ
ref struct RefToSpan
{
private readonly Span<int> _span;
public RefToSpan(Span<int> span) => _span = span;
// 例え _span に readonly が付いていても、this 書き換えが可能
public void Method(Span<int> span) { this = new RefToSpan(span); }
}
// readonly ref
readonly ref struct RORefToSpan
{
private readonly Span<int> _span;
public void Method(Span<int> span) { }
}
class Program
{
public static void LocalToRef(RefToSpan r)
{
Span<int> local = stackalloc int[1];
r.Method(local); // ここでエラーになる。r の中身が書き換えられることで、local が外に漏れる可能性を危惧
// 注: この例の場合は実際には漏れはしないものの、RefToSpan の作り次第なので保証はできない
}
public static void LocalToRORef(RORefToSpan r)
{
Span<int> local = stackalloc int[1];
r.Method(local); // readonly ref に対してなら OK
}
}
ローカルで定義したSpan<T>
を、引数で渡ってきたref
構造体のメソッドに対して渡しています。
この場合、readonly
がついている場合にだけコンパイルできます。
readonly
がついていない方では、メソッドの中でr
が書き換わる可能性があります。
その結果「ローカルのSpan<T>
が外に漏れる可能性がある」という判定を受けるため、コンパイル エラーになります。
readonly
がついている方では「書き換えがあり得ない」ということで、「外にも漏れない」という判定になります。
余談: さすがに unsafe までは追えない
参照がらみのフロー解析は、あくまでref
ローカル変数や、ref
構造体に対してだけ働きます。
unsafe
を使って、ポインターなどを介するとさすがに追跡できません。
例えば、以下のコードは不正で、実行時エラーであったり、予期しない動作を招く可能性があります。 しかし、コンパイラーが不正を判定できず、コンパイル時にエラーにすることができません。
unsafe static Span<int> X()
{
// ローカル
int x = 10;
// unsafe な手段でローカルなものの参照を作って返す
// これをやってしまうとまずいものの、コンパイル時にはエラーにできない
return new Span<int>(&x, 1);
}
「スタックのみ」制約
ref
構造体はスタック上に置かれている必要があります。
この性質から、ref
構造体は「stack-only 型」と呼ばれることもあります。
この制限が必要になるのは以下の2つの理由からです。
- そもそも参照自体がスタック上でしか働かない
- マルチスレッド動作時に安全性を保証できない
まず、ref
構造体以前に、参照自体がスタック上でしか使えません。
参照は、常にその参照の出所をトラッキングする必要があります。
例えば、出所がクラス(.NET のガベージ コレクションの管理下)の場合、
それを参照する方もガベージ コレクションのトラッキングの対象になります。
このトラッキング処理を低コストで行うためには、参照がスタック上になければなりません。
次に、マルチスレッド動作に関してですが、
Span<T>
の中身が論理的には (ref T Reference, int Length)
という2要素からなることによります。
安全に使うには、この2つがアトミックに読み書きされなければなりません。
もし、Reference
だけが書き換わり、Length
がまだ書き換わっていないタイミングで参照先を読み書きされてしまうと、
範囲チェックが正しく働かず、不正な領域を読み書きしてしまう危険性が出てきます。
ということで、「スタック上に置かれている必要がある」という制約が掛かります。 具体的には、以下のような制限があります。
- クラスのフィールドとして持てない(クラスに
ref
修飾子を付けれない理由はこれ) - クラスのフィールドに昇格する可能性があることができない
-
ボックス化できない
object
やdynamic
、インターフェイス型の変数に代入できないToString
など、object
型のメソッドを呼べない- インターフェイスを実装できない
- ジェネリック型引数として使えない
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
//❌ そもそもクラスに ref を付けれないのも stack-only を保証するため
ref class Class { }
//❌ インターフェイス実装
ref struct RefStruct : IDisposable { public void Dispose() { } }
class Program
{
//❌ 非同期メソッドの引数
static async Task Async(Span<int> x)
{
//❌ 非同期メソッドのローカル変数
Span<int> local = stackalloc int[10];
}
//❌ イテレーターの引数
static IEnumerable<int> Iterator(Span<int> x)
{
Span<int> local = stackalloc int[10];
local[0] = 1; //⭕ yield return をまたがないならOK
yield return local[0];
//❌ yield をまたいだ読み書き
local[0] = 2; // ダメ
}
static void Main()
{
Span<int> local = stackalloc int[1];
//❌ box 化
object obj = local;
//❌ object のメソッド呼び出し
var str = local.ToString();
//❌ クロージャ
Func<int> a1 = () => local[0];
int F() => local[0];
//❌ 型引数にも渡せない
List<Span<int>> list;
}
}
余談: TypedReference
「型付き参照」で説明しているTypedReference
型も、内部的に参照を持っている型の1つです。
TypedReference
は ref 構造体の仕様よりも古くからあって、昔はこの型だけに対して特殊対応をしていました。
その昔からある TypedReference
に対する特殊対応は、本項で説明している C# 7.2 から入った ref 構造体に対する制約よりもだいぶ緩くて、実は「スタック上に置かれている必要がある」制約から割かし簡単に外れることができました。
ちなみに、C# 7.2 で ref 構造体を導入後、
.NET Core 2.1 からは TypedReference
に対する特殊対応は止めて、単に TypedReference
を ref 構造体に変更したようです。
結果的に元よりも制約が厳しくなっていて、昔は(バグっている可能性が非常に高いものの)一応コンパイルできていたコードがコンパイル エラーになる可能性があります。
(ただ、TypedReference
自体利用頻度が非常に低いので問題にはなっていません。)