概要
前項では、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 フィールド
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 がついてないものの中はダメ。 }
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; }
エスケープ解析
参照を使う上では、「漏らしてはいけないものを漏らさない」ということが必要になります。 簡単に言うと、メソッド内のローカル変数はメソッドを抜けると消えるので、 その参照は外に漏らしてはいけません。
static ref int M() { int x = 123; // メソッド内の変数はメソッド抜けると消える。 return ref x; // エラー: 消えるものと外には漏らせない。 }
こういう「漏れている」状態を「エスケープ(escape: 脱走)している」と言います。
上記の例の場合は単純ですが、 参照変数などがあるため、間接的に何段も追いかける必要があります。
static ref int M() { int x = 123; // メソッド内の変数はメソッド抜けると消える。 ref var y = ref x; ref var z = ref y; return ref z; // エラー: 間に2段挟まっているものの、元は x なので外に漏らせない。 }
このように、間に何段か挟まっていようと、大本をたどってエスケープを避ける処理を「エスケープ解析」(escape analysis)と呼びます。
C# 7.2 で ref 構造体が、 C# 11 で ref フィールドが入ったわけですが、 エスケープ解析はこれらも考慮する必要があります。
例えばわざとちょっと複雑なことをすると、以下のように、いろいろなところに参照が伝搬するコードが書けます。
static void M(out Span<int> result) { int x = 123; var span = new Span<int>(ref x); // x が span から参照される状態。 scoped var r = new R(); var ret = r.M(span, out var y); // x がいろんなところに伝搬。 result = r.Span; // エラー: x が r.Span に伝搬してるかもしれないのでダメ。 result = y; // エラー: x が y に伝搬してるかもしれないのでダメ。 result = ret; // エラー: x が ret に伝搬してるかもしれないのでダメ。 } ref struct R { public Span<int> Span; public Span<int> M(Span<int> x, out Span<int> y) { Span = x; // フィールドにも、 y = x; // out 引数にも、 return x; // 戻り値にも x (が持ってる参照)が伝搬。 } }
コスト度外視でよければ、 「どの引数・フィールドが、他のどの引数・フィールド・戻り値に伝搬するか」を事細かに指定することで厳密なエスケープ解析ができます。 (C# では採用しなかったため)仮定的なコードにはなりますが、 先ほどのコードを以下のように書けるようにするという案はなくはないです。
static void M(out Span<int> result) { int x = 123; var span1 = new Span<int>(ref x); // x が span から参照される状態。 var span2 = new int[1]; // こちらは配列を参照しているので外に漏らしても大丈夫。 var r = new R { Span = span1 }; var ret = r.M(span2, out var y); // span2 → y, span1 → r.Span → ret と伝搬。 result = y; // 出どころが y → span2 → 配列 なので外に漏らして大丈夫。 result = ret; // 出どころが ret → r.Span → span1 → x なのでダメ。 } // 仮定的な文法: ` で、参照の伝搬先を表現。 ref struct R { public Span<int>`A Span; public Span<int>`A M(Span<int>`B x, out Span<int>`B y) { // 伝搬先の指定が違うので、以下のコードはダメ。 // Span = x; // return x; y = x; // `B 間の伝搬は OK。 return Span; // `A 間の伝搬は OK。 } }
scoped 修飾子
ただ、ここまで細かい指定に需要があるかというと微妙です。 そこで C# 11 では、以下の2種類だけに絞ることにしました。
- scoped: どこにも漏らさない。メソッドの中でだけ使う。
- unscoped: どこかに漏らす。
ref 構造体(Span<T>
など)に関しては実際にこの2択で、
何もつかなかった場合は unscoped 扱いで、scoped
という新しい修飾子を付けると scoped 扱いになります。
一方で、ref T
(ref
引数・ref
変数)に関しては、
既存コードを壊さないように、何もつけないと「引数から戻り値への伝搬だけ認める」(通称 return-only)というわかりにくいルールになっています。
そして、UnscopedRef
属性(System.Diagnostics.CodeAnalysis
名前空間)を付けると unscoped 扱い、
scoped
修飾子を付けると scoped 扱いになります。
(またちょっとややこしいことに、コンストラクターの引数の場合だけ、ref T
でも unscoped 扱いみたいです。)
実際のコードを見てみましょう。
まず、何もつけない場合(ref T
は return-only、ref 構造体は unscoped):
ref struct Default { private ref int _x; private Span<int> _y; // OK なやつ。 public Default(ref int x) => _x = ref x; public ref int ReturnRef(ref int x) => ref x; public ref int GetRef() => ref _x; public void UseRef(ref int x) { } public Default(Span<int> y) => _y = y; public Span<int> ReturnSpan(Span<int> y) => y; public Span<int> GetSpan() => _y; public void SetSpan(Span<int> y) => _y = y; public void UseSpan(Span<int> y) { } // エラーになるやつ。 // 引数 → フィールドへの伝搬だけ、ref T と Span<T> の挙動が違う。 // ref T は「引数 → 戻り値 だけは OK」(return-only)。 public void SetRef(ref int x) => _x = ref x; }
続いて、scoped
修飾子を付けた場合(いずれも scoped 扱い)、たいていのものがダメになります:
ref struct Scoped { private ref int _x; private Span<int> _y; // OK なやつ。 // フィールドにも戻りにも伝搬しない場合だけ OK。 public void UseRef(scoped ref int x) { } public void UseSpan(scoped Span<int> y) { } // エラーになるやつ。 // たいていダメ。 public Scoped(scoped ref int x) => _x = ref x; public ref int ReturnRef(scoped ref int x) => ref x; public void SetRef(scoped ref int x) => _x = ref x; public Scoped(scoped Span<int> y) => _y = y; public Span<int> ReturnSpan(scoped Span<int> y) => y; public void SetSpan(scoped Span<int> y) => _y = y; }
最後に、UnscopedRef
属性を付けた場合、たいていのものが OK になります
(ただし、ref 構造体は何も付けなくても unscoped 扱いなので、追加で属性を付けようとするとエラーになります):
using System.Diagnostics.CodeAnalysis; ref struct Unscoped { private ref int _x; private Span<int> _y; // OK なやつ。 // UnscopedRef 属性を付けるとなんでも OK に。 // (といっても差が出るのは SetRef だけ。) public Unscoped([UnscopedRef] ref int x) => _x = ref x; public ref int ReturnRef([UnscopedRef] ref int x) => ref x; public void SetRef([UnscopedRef] ref int x) => _x = ref x; public void UseRef([UnscopedRef] ref int x) { } // Span の方は「デフォルトで UnscopedRef だから属性付けるな」とエラーになる。 public Unscoped([UnscopedRef] Span<int> y) => _y = y; public Span<int> ReturnSpan([UnscopedRef] Span<int> y) => y; public void SetSpan([UnscopedRef] Span<int> y) => _y = y; public void UseSpan([UnscopedRef] Span<int> y) { } }
呼び出し元の挙動
この手の機能は、 「メソッド内でできることを制限する代わりに、呼び出し元でできることを増やす」というものです。
例えば、unscoped (何も修飾子を付けていない ref 構造体)の場合、以下のように、
Builder.Replace
の中で制限がない代わり、それを呼んでいる場所でのエラーが増えます。
var builder = new Builder(); Replace(ref builder); static void Replace(ref Builder builder) { Span<char> newBuffer = stackalloc char[3]; builder.Replace(newBuffer); // ダメ。stackalloc したものが builder 越しに外に漏れる。 } ref struct Builder(Span<char> initialBuffer) { private Span<char> _buffer = initialBuffer; public void Replace(Span<char> value) { // 参照先自体を書き換え。 // 引数からフィールドに参照が伝搬。 _buffer = value; } }
一方、scoped (scoped
修飾子を付けている)の場合、以下のように、
Builder.Replace
の中で制限が掛かる代わり、それを呼んでいる場所でのエラーがなくなります。
var builder = new Builder(); Append(ref builder); static void Append(ref Builder builder) { Span<byte> buffer = [0x61, 0x62, 0x63]; builder.Append(buffer); // 同じようなことをしていてもこれは OK。 } ref struct Builder(Span<char> initialBuffer) { private Span<char> _buffer = initialBuffer; public void Append(scoped ReadOnlySpan<byte> utf8) { // 中身を書き換え。参照先自体は元のまま。 // 引数の参照はどこにも漏らさない。 System.Text.Encoding.UTF8.GetChars(utf8, _buffer); } }
ちなみに、内部的には scoped
修飾子の方も属性で表現されています。
scoped
修飾子を付けた引数には ScopedRef
属性が付きます。
(ユーザーが自分の手でこの属性を付けることは認められていません。)
構造体の this
構造体の this
は参照になっています。
この参照はデフォルトで scoped 扱いになっていて、外に漏らすことができません。
using System.Diagnostics.CodeAnalysis; struct S { private int _x; public ref S RefThis() => ref this; public ref int RefX() => ref _x; }
この挙動を変えるのにも UnscopedRef
属性が使えます。
メソッド自身に UnscopedRef
属性を付けることで、this
が unscoped 扱いになります。
using System.Diagnostics.CodeAnalysis; struct S { private int _x; [UnscopedRef] public ref S RefThis() => ref this; [UnscopedRef] public ref int RefX() => ref _x; }
ref 構造体のインターフェイス実装
(書きかけ。リンク用。先にセクションだけ作成。)