Pull to refresh

Анализируем локальные функции в C# 7

Reading time 9 min
Views 31K
Original author: SergeyT
Добавление локальных функций в языке C# первоначально для меня было излишним. Прочитав статью в блоге SergeyT, я понял, что эта фича действительна нужна. Итак, кто сомневается в надобности локальных функций и кто еще не знает что это, вперед за знаниями!

Локальные функции — это новая возможность в C# 7, которая позволяет определять функцию внутри другой функции.

Когда использовать локальные функции?


Основная идея локальных функций очень похожа на анонимные методы: в некоторых случаях создание именованной функции слишком дорого с точки зрения когнитивной нагрузки на читателя. Иногда функциональность, по своей сути, является локальной для другой функции, и нет смысла загрязнять «внешнюю» область видимости отдельной именованной сущностью.

Вы можете подумать, что эта возможность избыточна, потому что такое же поведение может быть достигнуто с анонимными делегатами или лямбда-выражениями. Но это не всегда так. Анонимные функции имеют определенные ограничения, и их характеристики производительности могут быть непригодными для ваших сценариев.

Пример использования 1: предварительные условия в блоках итератора


Вот простая функция, которая читает файл по строкам. Вы знаете, когда будет выброшено ArgumentNullException?
public static IEnumerable<string> ReadLineByLine(string fileName)
{
    if (string.IsNullOrEmpty(fileName)) throw new ArgumentNullException(nameof(fileName));
    foreach (var line in File.ReadAllLines(fileName))
    {
        yield return line;
    }
}
 
// When the error will happen?
string fileName = null;
// Here?
var query = ReadLineByLine(fileName).Select(x => $"\t{x}").Where(l => l.Length > 10);
// Or here?
ProcessQuery(query);

Методы с yield return в теле являются особыми. Они называются блоками итератора, и они ленивы (lazy). Это означает, что выполнение этих методов происходит «по требованию», и первый блок кода в них будет выполняться только тогда, когда клиент метода вызовет MoveNext на результирующем итераторе. В нашем случае это означает, что ошибка произойдет только в методе ProcessQuery, потому что все LINQ-операторы тоже ленивы.

Очевидно, что такое поведение нежелательно, потому что метод ProcessQuery не будет иметь достаточной информации о контексте ArgumentNullException. Поэтому было бы неплохо бросить исключение сразу — когда клиент вызывает ReadLineByLine, но не тогда, когда клиент обрабатывает результат.

Чтобы решить эту проблему, нам нужно извлечь логику проверки в отдельный метод. Это хороший кандидат на анонимную функцию, но анонимные делегаты и лямбда-выражения не поддерживают блоки итераторов (*):

(*) Лямбда-выражения в VB.NET могут иметь блок итератора.
public static IEnumerable<string> ReadLineByLine(string fileName)
{
    if (string.IsNullOrEmpty(fileName)) throw new ArgumentNullException(nameof(fileName));
 
    return ReadLineByLineImpl();
 
    IEnumerable<string> ReadLineByLineImpl()
    {
        foreach (var line in File.ReadAllLines(fileName))
        {
            yield return line;
        }
    }
}


Пример использования 2: предварительные условия в асинхронных методах


Асинхронные методы имеют аналогичную проблему с обработкой исключений: любое исключение, созданное методом, помеченным ключевым словом async, проявляется в возвращенной задаче:
public static async Task<string> GetAllTextAsync(string fileName)
{
    if (string.IsNullOrEmpty(fileName)) throw new ArgumentNullException(nameof(fileName));
    var result = await File.ReadAllTextAsync(fileName);
    Log($"Read {result.Length} lines from '{fileName}'");
    return result;
}
 
string fileName = null;
// No exceptions
var task = GetAllTextAsync(fileName);
// The following line will throw
var lines = await task;

Вы можете подумать, что при возникновении ошибки нет большой разницы. Но это далеко от истины. Неисправная задача (faulted task) означает, что сам метод не смог выполнить то, что он должен был сделать. Неисправная задача означает, что проблема заключается в самом методе или в одном из блоков, от которых зависит метод.

Проверка надежных предварительных условий особенно важна, когда результирующая задача передается по системе. В этом случае было бы очень трудно понять, когда и что пошло не так. Локальная функция может решить эту проблему:
public static Task<string> GetAllTextAsync(string fileName)
{
    // Eager argument validation
    if (string.IsNullOrEmpty(fileName)) throw new ArgumentNullException(nameof(fileName));
    return GetAllTextAsync();
 
    async Task<string> GetAllTextAsync()
    {
        var result = await File.ReadAllTextAsync(fileName);
        Log($"Read {result.Length} lines from '{fileName}'");
        return result;
    }
}


Пример использования 3: локальная функция с блоками итератора


Мне было очень досадно, что нельзя использовать итераторы внутри лямбда выражений. Вот простой пример: если вы хотите получить все поля в иерархии типов (включая закрытые), вам нужно пройти иерархию наследования вручную. Но логика обхода специфична для конкретного метода и должна быть максимальная «локализованной»:
public static FieldInfo[] GetAllDeclaredFields(Type type)
{
    var flags = BindingFlags.Instance | BindingFlags.Public |
                BindingFlags.NonPublic | BindingFlags.DeclaredOnly;
    return TraverseBaseTypeAndSelf(type)
        .SelectMany(t => t.GetFields(flags))
        .ToArray();
 
    IEnumerable<Type> TraverseBaseTypeAndSelf(Type t)
    {
        while (t != null)
        {
            yield return t;
            t = t.BaseType;
        }
    }
}


Пример использования 4: рекурсивный анонимный метод


Анонимные функции по умолчанию не могут ссылаться на саму себя. Чтобы обойти это ограничение, вы должны объявить локальную переменную с типом делегата, а затем захватить эту локальную переменную внутри лямбда-выражения или анонимного делегата:
public static List<Type> BaseTypesAndSelf(Type type)
{
    Action<List<Type>, Type> addBaseType = null;
    addBaseType = (lst, t) =>
    {
        lst.Add(t);
        if (t.BaseType != null)
        {
            addBaseType(lst, t.BaseType);
        }
    };
 
    var result = new List<Type>();
    addBaseType(result, type);
    return result;
}

Этот подход не очень читабелен, и следующее решение с локальной функцией кажется более естественным:
public static List<Type> BaseTypesAndSelf(Type type)
{
    return AddBaseType(new List<Type>(), type);
 
    List<Type> AddBaseType(List<Type> lst, Type t)
    {
        lst.Add(t);
        if (t.BaseType != null)
        {
            AddBaseType(lst, t.BaseType);
        }
        return lst;
    }
}


Пример использования 5: когда вопросы аллокации имеют значение


Если вы когда-либо работали над критичным для производительности приложением, то вы знаете, что анонимные методы не из дешевых:
  • Накладные расходы на вызов делегата (очень маленькие, но они существуют).
  • Аллокация 2 объектов в управляемой куче, если лямбда-выражение захватывает локальную переменную или аргумент метода (один для экземпляра замыкания и другой для самого делегата).
  • Аллокация 1 объекта в управляемой кучи, если лямбда-выражение захватывает экземплярные поля объекта.
  • Отсутствие аллокаций будет только в том случае, если лямбда-выражение не захватывает ничего или оперирует лишь статическими членами.

Но модель аллокации для локальных функций существенно отличается.
public void Foo(int arg)
{
    PrintTheArg();
    return;
    void PrintTheArg()
    {
        Console.WriteLine(arg);
    }
}

Если локальная функция захватывает локальную переменную или аргумент, то компилятор C# генерирует специальную структуру замыкания, создает ее экземпляр и передает ее по ссылке в сгенерированный статический метод:
internal struct c__DisplayClass0_0
{
    public int arg;
}
 
public void Foo(int arg)
{
    // Closure instantiation
    var c__DisplayClass0_ = new c__DisplayClass0_0() { arg = arg };
    // Method invocation with a closure passed by ref
    Foo_g__PrintTheArg0_0(ref c__DisplayClass0_);
}
 
internal static void Foo_g__PrintTheArg0_0(ref c__DisplayClass0_0 ptr)
{
    Console.WriteLine(ptr.arg);
}

(Компилятор генерирует имена с недопустимыми символами, такими как < и >. Чтобы улучшить читаемость, я изменил имена и немного упростил код.)

Локальная функция может захватывать экземплярное состояние, локальные переменные (***) или аргументы. Никакой аллокации в управляемой куче не произойдет.
(***) Локальные переменные, используемые в локальной функции, должны быть определены (definitely assigned) в месте объявления локальной функции.

Есть несколько случаев, когда произойдет создание объекта в управляемой куче:

1. Локальная функция явно или неявно преобразуется в делегат.
Аллокация делегата произойдет если локальная функция захватывает поля экземплярные или статические поля, но не захватывает локальные переменные или аргументы.
public void Bar()
{
    // Just a delegate allocation
    Action a = EmptyFunction;
    return;
    void EmptyFunction() { }
}

Аллокация замыкания и делегата произойдет, если локальная функция захватывает локальные/аргументы
public void Baz(int arg)
{
    // Local function captures an enclosing variable.
    // The compiler will instantiate a closure and a delegate
    Action a = EmptyFunction;
    return;
    void EmptyFunction() { Console.WriteLine(arg); }
}


2. Локальная функция захватывает локальную переменную/аргумент, а анонимная функция захватывает переменную/аргумент из той же области видимости.
Этот более тонкий случай.

Компилятор C # генерирует отдельный тип замыкания для каждой лексической области видимости (аргументы метода и локальные переменные верхнего уровня находятся в одной и той же области верхнего уровня). В следующем случае, компилятор будет генерировать два типа замыкания:
public void DifferentScopes(int arg)
{
    {
        int local = 42;
        Func<int> a = () => local;
        Func<int> b = () => local;
    }
    Func<int> c = () => arg;
}

Два разных лямбда-выражения используют один и тот же тип замыкания, если они захватывают переменные из одной и той же области видимости. Сгенерированные методы для лямбда-выражений a и b находятся в одном и том же типе замыкания:
private sealed class c__DisplayClass0_0
{
    public int local;
 
    internal int DifferentScopes_b__0()
    {
        // Body of the lambda 'a'
        return this.local;
    }
 
    internal int DifferentScopes_b__1()
    {
        // Body of the lambda 'a'
        return this.local;
    }
}
 
private sealed class c__DisplayClass0_1
{
    public int arg;
 
    internal int DifferentScopes_b__2()
    {
        // Body of the lambda 'c'
        return this.arg;
    }
}
 
public void DifferentScopes(int arg)
{
    var closure1 = new c__DisplayClass0_0 { local = 42 };
    var closure2 = new c__DisplayClass0_1() { arg = arg };
    var a = new Func<int>(closure1.DifferentScopes_b__0);
    var b = new Func<int>(closure1.DifferentScopes_b__1);
    var c = new Func<int>(closure2.DifferentScopes_b__2);
}

В некоторых случаях такое поведение может вызвать некоторые очень серьезные проблемы, связанные с памятью. Вот пример:
private Func<int> func;
public void ImplicitCapture(int arg)
{
    var o = new VeryExpensiveObject();
    Func<int> a = () => o.GetHashCode();
    Console.WriteLine(a());
 
    Func<int> b = () => arg;
    func = b;
}

Кажется, что переменная o должна быть доступна для сборки мусора сразу после вызова делегата a(). Но это не так, поскольку два лямбда-выражения используют один и тот же тип замыкания:
private sealed class c__DisplayClass1_0
{
    public VeryExpensiveObject o;
    public int arg;
 
    internal int ImplicitCapture_b__0()
        => this.o.GetHashCode();
 
    internal int ImplicitCapture_b__1()
        => this.arg;
}
 
private Func<int> func;
 
public void ImplicitCapture(int arg)
{
    var c__DisplayClass1_ = new c__DisplayClass1_0()
    {
        arg = arg,
        o = new VeryExpensiveObject()
    };
    var a = new Func<int>(c__DisplayClass1_.ImplicitCapture_b__0);
    Console.WriteLine(func());
    var b = new Func<int>(c__DisplayClass1_.ImplicitCapture_b__1);
    this.func = b;
}

Это означает, что время жизни экземпляра замыкания привязано к времени жизни поля func: замыкания остается живым до тех пор, пока делегат доступен из кода приложения. Это может продлить время жизни VeryExpensiveObject, что по сути является своеобразной утечкой памяти.

Аналогичная проблема возникает, когда локальная функция и лямбда-выражение захватывают переменные из одной и той же области видимости. Даже если они захватывают разные переменные, тип замыкания будет общим, вызывая выделение объекта в управляемой куче:
public int ImplicitAllocation(int arg)
{
    if (arg == int.MaxValue)
    {
        // This code is effectively unreachable
        Func<int> a = () => arg;
    }
 
    int local = 42;
    return Local();
 
    int Local() => local;
}

Будет преобразовано компилятором в:
private sealed class c__DisplayClass0_0
{
    public int arg;
    public int local;
 
    internal int ImplicitAllocation_b__0()
        => this.arg;
 
    internal int ImplicitAllocation_g__Local1()
        => this.local;
}
 
public int ImplicitAllocation(int arg)
{
    var c__DisplayClass0_ = new c__DisplayClass0_0 { arg = arg };
    if (c__DisplayClass0_.arg == int.MaxValue)
    {
        var func = new Func<int>(c__DisplayClass0_.ImplicitAllocation_b__0);
    }
    c__DisplayClass0_.local = 42;
    return c__DisplayClass0_.ImplicitAllocation_g__Local1();
}

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

Локальные функции 101


Ниже приведен список наиболее важных аспектов локальных функций в C#:
  • Локальные функции могут определять блоки итераторов.
  • Локальные функции полезны для неотложенной (eager) проверки предусловий в асинхронных методах и блоках итераторов.
  • Локальные функции могут быть рекурсивными.
  • Локальные функции не аллоцируют в куче, если не происходит преобразование их в делегаты.
  • Локальные функции немного более эффективны, чем анонимные функции из-за отсутствия накладных расходов вызовов делегата (****).
  • Локальные функции могут быть объявлены после оператора return, что позволяет отделить основную логику метода от вспомогательной.
  • Локальные функции могут «скрыть» функцию с тем же именем, объявленным во внешней области видимости.
  • Локальные функции могут быть асинхронными и/или небезопасными (unsafe); другие модификаторы не допускаются.
  • Локальные функции не могут иметь атрибуты.
  • Локальные функции не очень дружественны к IDE: пока нет «рефакторинга для выделения локальных методов» (R# 2017.3 уже поддерживает такую возможность. — прим. пер), и если код с локальной функцией не компилируется, вы получите много «подчеркиваний» «squiggles» в среде IDE.

(****) Вот результаты микробенчмарка:
private static int n = 42;
 
[Benchmark]
public bool DelegateInvocation()
{
    Func<bool> fn = () => n == 42;
    return fn();
}
 
[Benchmark]
public bool LocalFunctionInvocation()
{
    return fn();
    bool fn() => n == 42;
}

Method
Mean
Error
StdDev
DelegateInvocation
1.5041 ns
0.0060 ns
0.0053 ns
LocalFunctionInvocation
0.9298 ns
0.0063 ns
0.0052 ns

Чтобы получить эти цифры, вам нужно вручную «декомпилировать» локальную функцию в обычную функцию. Причина этого проста: такая простая функция, как «fn», будет встроена (inline) во время выполнения, и тест не покажет реальную стоимость вызова. Чтобы получить эти числа, я использовал статическую функцию, отмеченную атрибутом NoInlining (к сожалению, вы не можете использовать атрибуты с локальными функциями).
Only registered users can participate in poll. Log in, please.
Оправдано ли включение локальных функций в C#?
59.9% Да, конечно. У них довольно много кейсов использования 124
31.4% Можно и обойтись без них 65
8.7% Они не нужны. 18
207 users voted. 49 users abstained.
Tags:
Hubs:
+34
Comments 19
Comments Comments 19

Articles