概要
前項では、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
自体利用頻度が非常に低いので問題にはなっていません。)
ref フィールド
※ 書きかけ。トラッキング issue: UfcppSample#413
Ver. 11
C# 11 で、ref 構造体のフィールドを ref
(参照渡し)で持てるようになりました。
これを ref フィールド(ref field)と言います。
ref フィールドの書き方は参照引数や参照戻り値と同じく、型の前に ref
修飾を付けます。
ref struct ByReference<T> { public ref T Value; }
C# 7.2 に頃に Span<T>
構造体の内部的な話で、「Span<T>
はランタイム側で特殊処理を入れている」というような話を書いていましたが、
ref フィールドが入ったことで、通常の C# コードで同様のことができるようになりました。
実際、.NET 7 からはそういう実装に置き換わっていて、Span<T>
の内部は晴れて以下のようなコードに変更されています。
ref struct Span<T> { internal readonly ref T _reference; private readonly int _length; }
ちなみに、ref フィールドを持てるのは ref 構造体だけです。 以下のコードはコンパイル エラーになります。
class A { ref int _x; // class 中はダメ。 } struct B { ref int _x; // struct も ref がついてないものの中はダメ。 }
参照渡しと ref 構造体のエスケープ解析
C# 7 で参照戻り値が入って以来、 C# では参照の出どころを追いかけて、危険な参照(参照先が消えてしまっているような状態)にならないかどうかを解析しています。 これをエスケープ解析といいます。
また、C# 7.2 で ref 構造体が入ってからは、ref 構造体に対してもエスケープ解析をしています。 ただ、この時点では参照引数・戻り値のエスケープ解析と、ref 構造体に対するエスケープ解析が連動していません。
C# 11 では ref フィールドの導入に伴って、これらのエスケープ解析がちゃんと連動するようになりました。
// 外から渡ってきた x から Span を作って、それを外に返す。 // これは問題ない。 static Span<int> m1(ref int x) => new Span<int>(ref x); static Span<int> m2() { // 中の変数を int x = 0; // 外に漏らしてるのでダメ。 // 「ref x が new Span() に伝搬」という処理が C# 10 まではできなかった。 // (C# 11 でできるようになったので、.NET 7 で Span<T> に Span(ref T reference) なコンストラクターが追加された。) return new Span<int>(ref x); }
参照の伝搬先が増えているので、エスケープ解析のルールは厳しくなっています。
例えば、以下の例で、m2
の中の「x
の参照が span
に伝搬」からの、「span
は戻り値にできない」は C# 10 の頃にはなかったルールです。
// 引数の ref int x が、戻り値の Span<int> に伝搬している扱い。 static Span<int> m1(ref int x) => new Span<int>(ref x); static Span<int> m2() { var x = 0; var span = m1(ref x); // x の参照を span が握ってる扱い。 return span; // 出どころが x (ローカル変数)なので、その参照は戻り値に返せない。 }
もちろん、この return span;
が許されてしまうとまずくて、C# 11 のルールの方が正確です。
これまでは new Span<int>(ref x)
の方を(unsafe なしでは)できないようにすることで問題を起こさないようにしていました。
ref オブジェクト初期化子
前述の new Span<T>(ref T)
のようにコンストラクターを通して参照が ref 構造体に伝わる他に、
ref フィールドに対してオブジェクト初期化子で参照を渡すこともできます。
var x = 1; var a = new A() { X = ref x // オブジェクト初期化子で ref。 }; ref struct A { public ref int X; }
もちろん、このオブジェクト初期化子越しの参照の伝搬でもちゃんとエスケープ解析が働きます。
m1(); int x = 1; m2(ref x); m3(); static A m1() { // どの参照も渡さず作った A var a = new A(); // 外に漏れても平気。 return a; } static A m2(ref int x) { // 引数で渡ってきた参照 x が A に伝搬 var a = new A() { X = ref x }; // 引数を戻り値に伝搬させても平気。 return a; } static A m3() { // ローカル変数の参照を A に渡す var x = 1; var a = new A() { X = ref x }; // ローカル変数をそのには漏らせないのでエラー。 return a; } ref struct A { public ref int X; }
readonly ref
C# 7.2 の頃に ref readonly
というものがありました。
これは、「参照先の値の変更不可」というものです。
一方で、ref フィールドになると、ref readonly
と readonly ref
の2種類の readonly ができます(あるは両方付けて readonly ref readonly
もできます)。
比較のためにまず、どちらの readonly もついていない状態ですが、 当然、「どこを参照するか変更」と「参照先の値の変更」のどちらもできます。
scoped var a = new A(); int x1 = 0; a.X = ref x1; // どこを参照するかを変更。 a.X = 2; // 参照先の値を変更 ref struct A { public ref int X; }
で、ref readonly
の方は C# 7.2 の頃からある意味と同じで、「参照先の値の変更不可」です。
scoped var a = new A(); int x1 = 0; a.X = ref x1; // どこを参照するかを変更。 a.X = 2; // エラー: 参照先の値を変更不可。 ref struct A { public ref readonly int X; }
一方、C# 11 から書ける readonly ref
は、要は、ref フィールド ref T X
を readonly にするという意味なので、「どこを参照するか変更」の方ができなくなります。
int x0 = 0; // readonly フィールドはコンストラクターでしか初期化できないので引数で渡す。 scoped var a = new A(ref x0); int x1 = 1; a.X = ref x1; // エラー: どこを参照するかを変更不可。 a.X = 2; // 参照先の値を変更はできる。 ref struct A { public readonly ref int X; public A(ref int x) => X = ref x; }
当然、両方の readonly
を付けると両方不可です。
int x0 = 0; // readonly フィールドはコンストラクターでしか初期化できないので引数で渡す。 scoped var a = new A(ref x0); int x1 = 1; a.X = ref x1; // エラー: どこを参照するかを変更不可。 a.X = 2; // エラー: 参照先の値を変更不可。 ref struct A { public readonly ref readonly int X; public A(ref int x) => X = ref x; }
scoped
新しい ref エスケープ解析ルールによって、
// 引数の span を返してる。 static Span<int> Unscope(Span<int> span) => span; static Span<int> M() { Span<int> buffer = stackalloc int[1]; // これはローカルの buffer を外に漏らすのでダメで当然。 return Unscope(buffer); }
一方で、
// 引数の span とは無関係の新しい Span<int> を返してる。 static Span<int> Scope(Span<int> span) => new int[1]; static Span<int> M() { Span<int> buffer = stackalloc int[1]; // 別に外には漏らさないのでかまわないはずだけど、このままだとエラーが残る。 return Scope(buffer); }
そこで、「引数でもらった ref 引数/ref 構造体を外には漏らしません」という宣言が必要 scoped 修飾子を付けます。
// 引数の span には scoped 修飾子を付ける。 static Span<int> Scope(scoped Span<int> span) => new int[1]; static Span<int> M() { Span<int> buffer = stackalloc int[1]; // scoped Span<int> になったことで、コンパイルできるようになる。 return Scope(buffer); }
この例は ref 構造体に scoped ref 引数にも付けれる
todo: 例
ちなみに、scoped 修飾子はローカル変数にも foreach (scoped ref var) とか for (scoped ref var ;; )も out scoped var も行けるようにするらしい?
派生クラス override
「using statement でも scoped 書けるように」みたいな PR 見たんだけども… using ref R r2 = ref r; が通らないので using scoped ref もダメなはず? 単に、using scoped ref が変なエラーメッセージ出さず、わかりやすいエラーにするための実装足しただけっぽい?
UnscopedRef 属性の例
this と out はデフォルトで scoped
static Span<int> m1(Span<int> x) { var clone = x.ToArray(); // 配列にコピー。x が持っている参照とは無関係になる。 return clone; } static Span<int> m2() { Span<int> x = stackalloc int[] { 1, 2, 3, 4 }; // stackalloc 揺らいで外には漏らせない。 var y = m1(x); // m1 の中身を知っていれば、x とは無関係の参照が返ってきていることがわかるものの… return y; // このままではそれがわからないので「x と関係あるかも」ということで外に漏らせない。 }
// ref 引数、もしくは、ref 構造体の引数に scoped を付けると、「この参照は外に漏らさない」という意味になる。 static Span<int> m1(scoped Span<int> x) { var clone = x.ToArray(); // 配列にコピー = 実際、x を外に漏らさない。 return clone; } static Span<int> m2() { Span<int> x = stackalloc int[] { 1, 2, 3, 4 }; // stackalloc 揺らいで外には漏らせない。 var y = m1(x); // scoped のおかげで、 y が x と無関係な保証あり。 return y; // なので、y を戻り値にして大丈夫。 }
ref フィールドの展開結果
ref フィールド自体は「ランタイム側の型システムの修正」
元々 .NET の型システムはint&
みたいな書き方で参照を表せて、これを引数や戻り値にできていました。
それが今回、「フィールドにも int&
が使えるようになった」というのが C# 11の ref フィールドです。
scoped は ScopedRef 属性
UnscopedRef だけ属性なの多少気持ち悪いものの
RefSafetyRules 属性
namespace System.Runtime.CompilerServices; [CompilerGenerated] [AttributeUsage(AttributeTargets.Module, AllowMultiple = false, Inherited = false)] internal sealed class RefSafetyRulesAttribute : Attribute { public readonly int Version; public RefSafetyRulesAttribute(int version) => Version = version; }