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

https://blogs.msdn.microsoft.com/seteplia/2017/10/03/dissecting-the-local-functions-in-c-7/
  • Перевод
Добавление локальных функций в языке 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 (к сожалению, вы не можете использовать атрибуты с локальными функциями).
Оправдано ли включение локальных функций в C#?

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

Поделиться публикацией
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама
Комментарии 19
  • 0

    Немножко дико выглядит код после return. Вы по какой-то причине так писали?
    Мне кажется, для читабельности лучше было бы использовать локальную функцию после её объявления.

    • +1
      Это перевод msdn. А так — как раз удобнее, если привыкнуть. Более лучше видно, что вы не проваливаетесь в тело функции из общего потока.
      • +2
        А мне нравится: сначала идет основная логика, сразу ясно, что выполняет функция. Детали — позже.
        • +1

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


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


          Но если такую практику принять, то и локальные функции должны располагаться после основного блока кода.

          • +4
            Данная практика распространена по причине того, как сторонний разработчик обычно знакомится с новым для него классом — ему в первую очередь интересно, что умеет делать класс и какие обязанности на себя он возлагает и может быть совсем не интересно как он это делает, потому детали реализации идут после.

            Разумеется это не всегда так, иногда разработчик уже знает что делает класс и хочет разобраться в конкретном методе, но тогда, скорее всего, он уже знает как и что ему конкретно искать.
            • 0
              Это похоже на распространенную в последнее время практику, когда приватные методы располагаются в классе после публичных.

              Некоторые вообще следуют принципам SOLID и выделяют публичную часть класса в отдельный интерфейс.


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

              При использовании IDE этой проблемы нет. В VS вообще можно прыгать через Alt+F12 с сохранением истории переходов.

              • 0
                Некоторые вообще следуют принципам SOLID и выделяют публичную часть класса в отдельный интерфейс.

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


                При использовании IDE этой проблемы нет. В VS вообще можно прыгать через Alt+F12 с сохранением истории переходов.

                Тем не менее, для аккуратности и читаемости кода лучше методы группировать.
                Сейчас в тренде группировать по области видимости в порядке, начиная с публичных, хотя раньше было не так.
                Это прослеживается и в новых MS исходниках, например, шаблоне Web API Core приложения.
                В целом, это дело моды и вкуса.

                • –1
                  > В случае наличия интерфейса, при рассмотрении реализации, скорее первыми хочется увидеть детали реализации, чтобы при просмотре публичных методов уже понимать, как что работает.

                  Какую именно часть деталей реализации вы хотите увидеть первой?
          • 0
            Не уверен в ценности опроса, поскольку полезность фичи зависит от контекста. По моим наблюдениям, малые команды обычно пишут малые и типовые проекты, часто без активного использования async и Enumerable (везде используется List).

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

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

            Небольшой нетехнический момент: в раздел 101 можно добавить пункт о форматировании кода. Например, в коде есть публичный метод на 30 строк, разбитый на
            public void DoSomething()
            {
              DoFirstPart();
              DoSecondPart();
              DoThirdPart();
            }
            ..
            // Private methods Do*Part here
            

            В итоге в классе лежат 4 метода. Если вынести Do*Part методы в локальные функции — остается лишь один метод, сор из DoSomething не выносится.
            • 0
              часто без активного использования async и Enumerable (везде используется List).

              То есть, Linq вообще не используют?

              В итоге в классе лежат 4 метода. Если вынести Do*Part методы в локальные функции — остается лишь один метод, сор из DoSomething не выносится.

              А вот это, ИМХО, перебор, 4 локальных функции…
              • +1
                То есть, Linq вообще не используют?


                Наверное имелось в виду, что yield return не используют.
                • 0
                  Linq используют, но из любого потенциального IEnumerable метода возвращают result.ToList();
                  Как выше заметил Naglec — не используют yield return.

                  По поводу функций вопрос открыт. В любом случае у нас есть три выделенных куска кода. Какая разница где их хранить, в теле класса как приватные методы, или в теле метода как локальные функции? В первом случае мы «засоряем» интерфейс класса, пускай и в приватной части, во втором случае мы «засоряем» лишь метод. В случае ЛФ сразу видно где код используется, в случае приватных методов проще переиспользовать код.
                  • +1
                    Мы не засоряем интерфейс класса, т.к. функции приватны.
                    Конечно, это выбор отдельно взятого человека/команды. Но на мой вкус — это перебор.
                    • +3
                      В первом случае мы «засоряем» интерфейс класса, пускай и в приватной части, во втором случае мы «засоряем» лишь метод. В случае ЛФ сразу видно где код используется, в случае приватных методов проще переиспользовать код.


                      Но в случае ЛФ сразу возрастает уровень вложенности, что тоже читабельности не добавляет.

                      Лично я пока вижу адекватный сценарий использования ЛФ только в виде описания небольшой логики (в пределах 5-10 строк), которая нигде за пределами метода использоваться не будет. И, наверное, не более одной ЛФ на метод.
                • +2

                  Спасибо за подробный обзор локальных функций!


                  Локальные функции и анонимные блоки кода имеют разную семантику.
                  Анонимные блоки на то и анонимные, чтобы использовать их как лямбда-функции (передавать как стратегии в метод или класс).
                  А локальные функции это локальные функции.
                  И в силу отличий в семантике и технических причин, анонимные блоки не могут заменить собой локальные функции во всех случаях.


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


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


                  Но есть сомнения, что они приживутся — появились слишком поздно, коммьюнити C# зрелое, в основной своей части консервативное, общепринятые подходы и практики наработаны.


                  И вряд ли в командах локальные функции будут без вопросов проходить через ревью, хотя область их применения вполне просматривается:


                  1. В основном методе есть повторяющиеся участки, которые нецелесообразно выносить во внешний метод, т.к. новый (локальный) метод имеет смысл только в контексте основного.
                    Вынесение такого участка по внешний метод привносит "замусоривание" кода класса и риск того, что в дальнейшем кто-то может посчитать такой метод самодостаточным, дорабатывать и вызывать его в других методах класса.
                  2. Бывает, что код метода нельзя сделать короче одного-двух экранов, но в нем просматриваются отдельные блоки, которые для читаемости основной логики есть смысл вынести в отдельные методы.
                    Тогда можно провести декомпозицию основного метода, вынеся блоки в локальные методы (локальные — по тем же причинам).
                  • 0
                    И в обоих случаях проблема решается созданием private nested класса. Вообще, локальные функции — шутка очень удобная. Но с дополнительным классом код получается более аккуратным.
                    • –2
                      Создавать еще одну сущность (пусть и private nested) ради метода? о_О Это еще менее логично, чем просто private метод, который используется только один раз.
                      • +1
                        Ну да. Я не понимаю боязни создания новых сущностей. И почему ради метода? Ради нескольких методов («в нём просматриваются отдельные блоки, которые для читаемости основной логики есть смысл вынести в отдельные методы»).

                        А ещё дело в том, что локальные методы имеют ненулевой оверхед (описано в публикации) при обращении к сущностям вне локального метода. И учитывая, что, в отличие от лямбд в С++, захват осуществляется неявно, его довольно трудно проследить в сложном коде.
                      • +2

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


                        Соответственно, и для случая, когда декомпозируется метод на методы, имеющие смысл только в его контексте, можно реализовать декомпозицию двумя способами (при условии, что не выносим на верхний уровень "подметоды"):


                        • Локальные методы.
                        • Отдельная сущность, для решения задачи, которую до этого решал большой метод. И поскольку сущность эта будет небольшая (разделили метод на 2-3-4 метода и вынесли их в новую сущность), то и вопрос группировки становится неактуальным.

                        Какой метод выбрать — видимо, решать на месте, в зависимости от количества и объема "подметодов".

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.