C# 7での新しいスコープ ルール

Ver. 7

C# 7では、新機能の導入に伴って、それ以前にはなかったスコープ関連のルールが発生しています。

Ver. 7.3

ちなみに、C# 7.0の時点では、「式中での変数宣言」が使えるのは、関数本体(メソッドなどの{}の中や=>の後ろの部分)の中の式だけでした。 また、クエリ式内では変数宣言できませんでした。

これに対して、C# 7.3からはこの制限がなくなり、 クエリ式やコンストラクター初期化子などの中でも変数宣言できるようになりました。

式中での変数宣言

C# 6以前であれば、変数の宣言は宣言ステートメントでしかできませんでした。 そして、その宣言ステートメントを囲うブロックが、変数のスコープになります。

ちなみに、ブロックを持たない宣言ステートメントは書けません。 「ブロックを持たない」というのは、例えば、if ステートメントや foreach ステートメント直下です。 以下のようなコードはコンパイル エラーになります。

if (true)
    int x = 10; // コンパイル エラー

if (true)
{
    int x = 10; // これなら OK
}

foreach (var n in new[] { 1 })
    int x = 10; // コンパイル エラー

foreach (var n in new[] { 1 })
{
    int x = 10; // これなら OK
}

このifやforeach直下の部分を、構文上は埋め込みステートメント(embedded statement)と呼びます。 つまり、変数宣言ステートメントは、埋め込みステートメントに含まれていません。

ということで、C# 6までは「変数のスコープと言えばそれを囲うブロック内」というシンプルなルールで説明が付きました。

ところが、C# 7で導入されたis 演算子の拡張と[出力変数宣言]では、式の中で変数宣言ができます。 式は割かしどこにでも書けるものなので、実質的に、ほぼどこででも変数宣言できるようになりました。

static void M(object obj)
{
    if (obj is int x1) // 条件式内
        ;

    foreach (var n in obj is int x2 ? "a" : "b") // foreach の () 内
        ;

    for (var n = 0; obj is int x3 ? n < x3 : false; n++) // for の () 内
        ;

    if (true)
        Console.WriteLine(obj is int x4 ? 1 : 2); // 埋め込みステートメント内

    foreach (var n in "a")
        Console.WriteLine(obj is int x5 ? 1 : 2); // 埋め込みステートメント内
}

そうなると問題は、式中で宣言した変数のスコープがどうなるかです。 これには、仕様を決める段階で紆余曲折あったんですが、「式を囲うブロック、埋め込みステートメント、while、for、foreach、using、 case内」ということになりました。

if (true)
{
    Console.WriteLine(obj is int x ? 1 : 2); // もちろん、ブロック内がスコープ
    x = 1; // これは OK
}

if (true)
    Console.WriteLine(obj is int x ? 1 : 2); // 埋め込みステートメント内がスコープ

foreach (var n in obj is int x ? "a" : "b") // foreach 内がスコープ
    ;

for (var n = 0; obj is int x ? n < x : false; n++) // for 内がスコープ
    ;

while (obj is int x) // while 内がスコープ
{
    obj = "";
}

using (obj is IDisposable x ? x : null) // using 内がスコープ
    ;

// どの x ももうスコープ外。コンパイル エラー
x = 10;

特に、forステートメントの更新式の部分で宣言された変数のスコープは、更新式内だけになります。 (ループ本体の中からすら参照できない。)

for (int i = 0; i < 100; i += obj is int x ? x : 1) // この x はこの式内でだけ使える
{
    var x = "別の値"; // OK。更新式内の x とは別物
}

また、switch-case では以下のような書き方もできます。

switch (obj)
{
    case int x: return x;
    case string x: return x.Length; // int x の方とは別になる
    default: throw new IndexOutOfRangeException();
}

一方で、if ステートメントの条件式ではスコープが区切られません。そのifを囲うブロックがスコープになります。

if (obj is int x1) // 条件式内
{
}
else
{
    x1 = 10; // ここも x1 のスコープ
}

Console.WriteLine(x1); // ここも x1 のスコープ

これは、いわゆる「early return」(if (条件) { 長い処理 } の代わりに、if (!条件) return; で処理を打ち切ってしまうパターン)で変数宣言をしたいという要件が多いからだそうです。

void M(string s)
{
    if (!int.TryParse(s, out var x)) return;

    // x を使った長い処理
}

ラムダ式

ラムダ式では、ブロックを使った () => { } というようなものと、 => に続けて式を直接書く () => x というようなものの2パターンの記法が使えます。 後者であっても、この中で宣言した変数のスコープはラムダ式内に限られます。 (要するに、() => x みたいなののxの部分は、前述の「埋め込みステートメント」と同じ扱いになっています。)

Func<string, int> f = s => int.TryParse(s, out var x) ? x : -1;
f("123");
Console.WriteLine(x); // ここで x は使えない

余談: is 演算子で新しい変数を導入

Swift など、他のプログラミング言語の一部では、(C#風に書くと)以下のような構文を持っているものがあります。

using System;

class Base { }
class Derived1 : Base { public int Id => 1; }
class Derived2 : Base { public string Name => "2"; }

class Sample
{
    public static void M(Base x)
    {
        if (x is Derived1)
        {
            // この中では、x を Derived1 として扱える
            Console.WriteLine(x.Id);
        }
        else if (x is Derived2)
        {
            // この中では、x を Derived2 として扱える
            Console.WriteLine(x.Name);
        }
    }
}

is演算子の拡張は、C# 7でもこういう「型による分岐」機能がほしいということで入った機能です。 しかし、Swiftのような構文だと、「スコープ内で識別子の意味を変えない・上書かない」という原則に反します。 xは最初にBase型として定義した以上、ずっとBase型のままにしたいということです。

結局、is演算子の拡張は以下のように、式の中で新しい変数を導入する構文になっています。

public static void M(Base x)
{
    if (x is Derived1 d1)
    {
        // x の型が Derived1 だった場合だけ、キャスト結果が d1 に入る
        Console.WriteLine(d1.Id);
    }
    else if (x is Derived2 d2)
    {
        // x の型が Derived2 だった場合だけ、キャスト結果が d2 に入る
        Console.WriteLine(d2.Name);
    }
}

ローカル関数を使える範囲

ローカル関数はどう扱うべきでしょうか。 ローカル変数のようなものだと考えると、宣言より前では使えないはずです。 一方で、メソッドのようなものだと考えると、通常、メソッドは宣言よりも前で使えます。

using System;

class Program
{
    static void Main()
    {
        // ローカル関数は、こういうローカル変数的な扱いすべき?
        Func<int, int> f = x => x * x;

        // もしローカル変数的に扱うなら、f はこの後ろでしか使えない
        var y = f(2);

        // それとも、メソッドと同じような扱いにすべき?
        // メソッドなら、宣言よりも前でも使える
        var z = M(2);
    }

    // メソッドであれば、宣言が後ろにあってもいい
    static int M(int x) => x * x;
}

これは結局、後者が選ばれました。すなわち、メソッド的に、宣言よりも前で使えます。

static void Main()
{
    // ローカル関数は宣言より前で使える
    var y = f(2);

    int f(int x) => x * x;
}

もう1つ、ローカル関数が絡むと、「確実な代入ルール」も少々複雑です。 ローカル関数が周りのローカル変数をキャプチャする際、 その変数は、初めてローカル関数を呼び出すまでに初期化すればよいということになっています。

static void SuccessfulSample()
{
    int a; // 未初期化
    int f(int x) => a * x; // (この時点で)未初期化変数 a 参照
    a = 10; // ここで初期化
    var y = f(2); // OK
}

static void ErroneousSample()
{
    int a; // 未初期化
    int f(int x) => a * x; // 未初期化変数 a 参照
    // 初期化しない!
    var y = f(2); // コンパイル エラー
}

クエリ式

Ver. 7.3

C# 7.3までは、クエリ式中では式中での変数宣言ができませんでした。 (変数のスコープをどうするかがちょっと悩ましく、7.0時点では「先送り」していました。) C# 7.3で、これが許されるようになりました。

var q =
    from s in new[] { "a", "abc", "112", "132", "451", null }
    where s is string x && x.Length > 1
    where int.TryParse(s, out var x) && (x % 3) == 0
    select s;

ちなみに、この場合、変数のスコープは「句の中のみ」に限られます (whereとかselectとかによってスコープが区切られます)。 上記の例の場合、1つ目のwhere中のxと、2つ目のwhere中のxはそれぞれ別変数になります。

これは、クエリ式が実際には以下のようなメソッド チェーンに展開されるためです。

var q =
    new[] { "a", "abc", "112", "132", "451", null }
    .Where(s => s is string x && x.Length > 1)
    .Where(s => int.TryParse(s, out var x) && (x % 3) == 0);

前述の通り、ラムダ式内で変数宣言した場合、その変数のスコープはラムダ式内に限られます。 クエリ式は句ごとに1つのラムダ式が作られるので、それとの整合性を取った結果が「句ごとに別スコープ」です。 句をまたいだ変数を宣言したい場合はletを使ってください。

コンストラクター初期子、フィールド初期化子、プロパティ初期化子

Ver. 7.3

ラムダ式同様、スコープをどうするか悩ましくて保留になっていたものに初期化子があります。 C# 7.3で、以下のように、初期化子内でも変数宣言できるようになりました。

using System;

class Derived : base
{
    public Derived(string s) : this(int.TryParse(s, out var x) ? x : -1)
    {
        // コンストラクター初期化子中で宣言した x は、コンストラクター本体内で利用可能。
        Console.WriteLine(x);
    }

    public Derived(int a) : base(out var x)
    {
        // base の場合でも同様。
        Console.WriteLine(x);
    }

    // フィールド初期化子、プロパティ初期化子中で宣言した x は、その初期化子内でのみ有効。
    public int Field = int.TryParse("123", out var x) ? x : -1;
    public int Property{ get; set; } = int.TryParse("123", out var x) ? x : -1;
}

ちなみに、コンストラクター初期化子内で宣言した変数のスコープはそのコンストラクター全体、 フィールド初期化子・プロパティ初期化子中のものはその初期化しない限定です。

更新履歴

ブログ