Назначение локальных функций делегатам

В С# 7.0 вы можете объявлять локальные функции, т.е. функции, живущие внутри другого метода. Эти локальные функции могут обращаться к локальным переменным окружающего метода. Поскольку локальные переменные существуют только во время вызова метода, я задавался вопросом, можно ли назначить локальную функцию для делегата (который может жить дольше, чем этот вызов метода).

public static Func<int,int> AssignLocalFunctionToDelegate()
{
    int factor;

    // Local function
    int Triple(int x) => factor * x;

    factor = 3;
    return Triple;
}

public static void CallTriple()
{
    var func = AssignLocalFunctionToDelegate();
    int result = func(10);
    Console.WriteLine(result); // ==> 30
}

Это действительно работает!

Мой вопрос: почему это работает? Что здесь происходит?

Ответ 1

Это работает, потому что компилятор создает делегат, который фиксирует переменную factor в закрытии.

Фактически, если вы используете декомпилятор, вы увидите, что генерируется следующий код:

public static Func<int, int> AssignLocalFunctionToDelegate()
{
    int factor = 3;
    return delegate (int x) {
        return (factor * x);
    };
}

Вы можете видеть, что factor будет зафиксировано в закрытии. (Вероятно, вы уже знаете, что за кулисами компилятор будет генерировать класс, содержащий поле для хранения factor.)

На моей машине он создает следующий класс для закрытия:

[CompilerGenerated]
private sealed class <>c__DisplayClass1_0
{
    // Fields
    public int factor;

    // Methods
    internal int <AssignLocalFunctionToDelegate>g__Triple0(int x)
    {
        return (this.factor * x);
    }
}

Если я изменил AssignLocalFunctionToDelegate() на

public static Func<int, int> AssignLocalFunctionToDelegate()
{
    int factor;
    int Triple(int x) => factor * x;
    factor = 3;
    Console.WriteLine(Triple(2));
    return Triple;
}

то реализация будет:

public static Func<int, int> AssignLocalFunctionToDelegate()
{
    <>c__DisplayClass1_0 CS$<>8__locals0;
    int factor = 3;
    Console.WriteLine(CS$<>8__locals0.<AssignLocalFunctionToDelegate>g__Triple0(2));
    return delegate (int x) {
        return (factor * x);
    };
}

Вы можете увидеть, что он создает экземпляр класса, созданного компилятором, для использования с Console.WriteLine().

То, что вы не видите, это то, где он фактически присваивает 3 factor в декомпилированном коде. Чтобы убедиться в этом, вам нужно взглянуть на сам ИЛ (это может быть неудача в декомпиляторе, который я использую, который довольно старый).

IL выглядит следующим образом:

L_0009: ldc.i4.3 
L_000a: stfld int32 ConsoleApp3.Program/<>c__DisplayClass1_0::factor

Загружает постоянное значение 3 и сохраняет его в поле factor класса замыкания, созданного компилятором.

Ответ 2

Поскольку локальные переменные существуют только при вызове метода,

Это утверждение неверно. И как только вы верите в ложное утверждение, ваша целая цепочка рассуждений больше не звучит.

"Время жизни не дольше, чем активация метода" не является определяющей характеристикой локальных переменных. Определяющей характеристикой локальной переменной является то, что имя переменной имеет смысл только для кода в локальной области переменной.

Не сжимайте область с пожизненным сроком службы! Это не одно и то же. Lifetime - это концепция времени выполнения, описывающая процесс восстановления хранилища. Сфера - это концепция времени компиляции, описывающая, как имена связаны с языковыми элементами. Локальные переменные называются locals из-за их локальной области; их местность - это все их имена, а не их жизнь.

Локальные переменные могут продлить или сократить время их жизни произвольно по причинам эффективности или корректности. В С# нет каких-либо требований, чтобы локальные переменные имели только время жизни при активации метода.

Но вы уже знали, что:

IEnumerable<int> Numbers(int n)
{
  for (int i = 0; i < n; i += 1) yield return i;
}
...
var nums = Numbers(7);
foreach(var num in nums)
  Console.WriteLine(num);

Если время жизни локалей я и n ограничено этим методом, то как я и n могут иметь значения после возвращения Numbers?

Task<int> FooAsync(int n)
{
  int sum = 0;
  for(int i = 0; i < n; i += 1)
    sum += await BarAsync(i);
  return sum;
}
...
var task = FooAsync(7);

FooAsync возвращает задачу после первого вызова BarAsync. Но каким-то образом sum и n и i продолжаю иметь значения, даже после того, как FooAsync вернется к вызывающему.

Func<int, int> MakeAdder(int n)
{
  return x => x + n;
}
...
var add10 = MakeAdder(10);
Console.WriteLine(add10(20));

Каким-то образом n прилипает даже после MakeAdder.

Локальные переменные могут легко жить после возвращения метода, который активировал их; это происходит постоянно в С#.

Что здесь происходит?

Локальная функция, преобразованная в делегат, логически не сильно отличается от лямбда; поскольку мы можем преобразовать lambdas в делегатов, чтобы мы могли преобразовать локальные методы в делегатов.

Еще один способ подумать: предположим, что вместо этого ваш код:

return y=>Triple(y);

Если вы не видите никаких проблем с этой лямбдой, тогда не должно быть никаких проблем с простое return Triple; - опять же, эти два фрагмента кода являются логически одной и той же операцией, поэтому, если есть стратегия реализации для одного, тогда есть стратегия реализации для другой.

Обратите внимание, что вышесказанное не предполагает, что команда компилятора должна генерировать локальные методы как lambdas с именами. Команда компилятора, как всегда, может свободно выбирать любую стратегию реализации, которая им нравится, в зависимости от того, как используется локальный метод. Так же, как команда компилятора имеет много незначительных вариаций в стратегии создания преобразования лямбда-делегирования в зависимости от деталей лямбда.

Если, например, вы заботитесь об эффектных последствиях этих различных стратегий, то, как всегда, нет альтернативы проверять реалистичные сценарии и получать эмпирические измерения.