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

Ver. 7

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

式の中で変数宣言

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 や while の条件式ではスコープが区切られません。そのifやwhileを囲うブロックがスコープになります。

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

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

余談: 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 d)
    {
        // x の型が Derived1 だった場合だけ、キャスト結果が d に入る
        Console.WriteLine(d.Id);
    }
    else if (x is Derived2 d)
    {
        // x の型が Derived2 だった場合だけ、キャスト結果が d に入る
        // d のスコープは if 直後の(条件が真の時の)ブロック内だけ
        // x is Derived1 d の方とこっちの d は別物
        Console.WriteLine(d.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); // コンパイル エラー
}

更新履歴

ブログ