Анализируем локальные функции в C# 7
Добавление локальных функций в языке C# первоначально для меня было излишним. Прочитав статью в блоге SergeyT, я понял, что эта фича действительна нужна. Итак, кто сомневается в надобности локальных функций и кто еще не знает что это, вперед за знаниями!
Локальные функции — это новая возможность в C# 7, которая позволяет определять функцию внутри другой функции.
Когда использовать локальные функции?Основная идея локальных функций очень похожа на анонимные методы: в некоторых случаях создание именованной функции слишком дорого с точки зрения когнитивной нагрузки на читателя. Иногда функциональность, по своей сути, является локальной для другой функции, и нет смысла загрязнять «внешнюю» область видимости отдельной именованной сущностью.
Вы можете подумать, что эта возможность избыточна, потому что такое же поведение может быть достигнуто с анонимными делегатами или лямбда-выражениями. Но это не всегда так. Анонимные функции имеют определенные ограничения, и их характеристики производительности могут быть непригодными для ваших сценариев.
Пример использования 1: предварительные условия в блоках итератораВот простая функция, которая читает файл по строкам. Вы знаете, когда будет выброшено ArgumentNullException?
Методы с yield return в теле являются особыми. Они называются блоками итератора, и они ленивы (lazy). Это означает, что выполнение этих методов происходит «по требованию», и первый блок кода в них будет выполняться только тогда, когда клиент метода вызовет MoveNext на результирующем итераторе. В нашем случае это означает, что ошибка произойдет только в методе ProcessQuery, потому что все LINQ-операторы тоже ленивы.
Очевидно, что такое поведение нежелательно, потому что метод ProcessQuery не будет иметь достаточной информации о контексте ArgumentNullException. Поэтому было бы неплохо бросить исключение сразу — когда клиент вызывает ReadLineByLine, но не тогда, когда клиент обрабатывает результат.
Чтобы решить эту проблему, нам нужно извлечь логику проверки в отдельный метод. Это хороший кандидат на анонимную функцию, но анонимные делегаты и лямбда-выражения не поддерживают блоки итераторов (*):
(*) Лямбда-выражения в VB.NET могут иметь блок итератора.
Пример использования 2: предварительные условия в асинхронных методахАсинхронные методы имеют аналогичную проблему с обработкой исключений: любое исключение, созданное методом, помеченным ключевым словом async, проявляется в возвращенной задаче:
Вы можете подумать, что при возникновении ошибки нет большой разницы. Но это далеко от истины. Неисправная задача (faulted task) означает, что сам метод не смог выполнить то, что он должен был сделать. Неисправная задача означает, что проблема заключается в самом методе или в одном из блоков, от которых зависит метод.
Проверка надежных предварительных условий особенно важна, когда результирующая задача передается по системе. В этом случае было бы очень трудно понять, когда и что пошло не так. Локальная функция может решить эту проблему:
Пример использования 3: локальная функция с блоками итератораМне было очень досадно, что нельзя использовать итераторы внутри лямбда выражений. Вот простой пример: если вы хотите получить все поля в иерархии типов (включая закрытые), вам нужно пройти иерархию наследования вручную. Но логика обхода специфична для конкретного метода и должна быть максимальная «локализованной»:
Пример использования 4: рекурсивный анонимный методАнонимные функции по умолчанию не могут ссылаться на саму себя. Чтобы обойти это ограничение, вы должны объявить локальную переменную с типом делегата, а затем захватить эту локальную переменную внутри лямбда-выражения или анонимного делегата:
Этот подход не очень читабелен, и следующее решение с локальной функцией кажется более естественным:
Пример использования 5: когда вопросы аллокации имеют значение- Накладные расходы на вызов делегата (очень маленькие, но они существуют).
- Аллокация 2 объектов в управляемой куче, если лямбда-выражение захватывает локальную переменную или аргумент метода (один для экземпляра замыкания и другой для самого делегата).
- Аллокация 1 объекта в управляемой кучи, если лямбда-выражение захватывает экземплярные поля объекта.
- Отсутствие аллокаций будет только в том случае, если лямбда-выражение не захватывает ничего или оперирует лишь статическими членами.
Если локальная функция захватывает локальную переменную или аргумент, то компилятор C# генерирует специальную структуру замыкания, создает ее экземпляр и передает ее по ссылке в сгенерированный статический метод:
(Компилятор генерирует имена с недопустимыми символами, такими как < и >. Чтобы улучшить читаемость, я изменил имена и немного упростил код.)
Локальная функция может захватывать экземплярное состояние, локальные переменные (***) или аргументы. Никакой аллокации в управляемой куче не произойдет. (***) Локальные переменные, используемые в локальной функции, должны быть определены (definitely assigned) в месте объявления локальной функции.
Есть несколько случаев, когда произойдет создание объекта в управляемой куче:
1. Локальная функция явно или неявно преобразуется в делегат. Аллокация делегата произойдет если локальная функция захватывает поля экземплярные или статические поля, но не захватывает локальные переменные или аргументы.
Аллокация замыкания и делегата произойдет, если локальная функция захватывает локальные/аргументы
2. Локальная функция захватывает локальную переменную/аргумент, а анонимная функция захватывает переменную/аргумент из той же области видимости. Этот более тонкий случай.
Компилятор C # генерирует отдельный тип замыкания для каждой лексической области видимости (аргументы метода и локальные переменные верхнего уровня находятся в одной и той же области верхнего уровня). В следующем случае, компилятор будет генерировать два типа замыкания:
Два разных лямбда-выражения используют один и тот же тип замыкания, если они захватывают переменные из одной и той же области видимости. Сгенерированные методы для лямбда-выражений a и b находятся в одном и том же типе замыкания:
В некоторых случаях такое поведение может вызвать некоторые очень серьезные проблемы, связанные с памятью. Вот пример:
Кажется, что переменная o должна быть доступна для сборки мусора сразу после вызова делегата a(). Но это не так, поскольку два лямбда-выражения используют один и тот же тип замыкания:
Это означает, что время жизни экземпляра замыкания привязано к времени жизни поля func: замыкания остается живым до тех пор, пока делегат доступен из кода приложения. Это может продлить время жизни VeryExpensiveObject, что по сути является своеобразной утечкой памяти.
Аналогичная проблема возникает, когда локальная функция и лямбда-выражение захватывают переменные из одной и той же области видимости. Даже если они захватывают разные переменные, тип замыкания будет общим, вызывая выделение объекта в управляемой куче:
Будет преобразовано компилятором в:
Как вы можете видеть, все локальные переменные из верхней области видимости теперь становятся частью класса замыкания, что приводит к созданию объекта-замыкания, даже когда локальная функция и лямбда-выражение захватывают разные переменные.