目次

キーワード

概要

前項では、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構造体に対してだけ働きます。 unsaefを使って、ポインターなどを介するとさすがに追跡できません。

例えば、以下のコードは不正で、実行時エラーであったり、予期しない動作を招く可能性があります。 しかし、コンパイラーが不正を判定できず、コンパイル時にエラーにすることができません。

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 修飾子を付けれない理由はこれ)
  • クラスのフィールドに昇格する可能性があることができない
  • ボックス化できない
    • objectdynamic、インターフェイス型の変数に代入できない
    • 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構造体とは似て非なる制限のかかり方をしています。

理想を言うとref構造体という仕様が入った今、TypedReferenceref構造体として扱う方がいいでしょう。 しかし、既存の挙動を変えてまでTypedReferenceref構造体の挙動をそろえたいとは誰も思わないので、 TypedReference の挙動はこれまで通りになります。ref修飾子も追加されていません。

更新履歴

ブログ