Тонкости C# : то что вы всегда хотели знать, но боялись спросить
Всем привет. Меня зовут Владислав Фурдак, я .NET-техлид и работаю c платформой .NET около 10 лет. Ранее писал на DOU на темы карьеры, а также технические статьи: «Асинхронность в C#», «Эволюция .NET-стека», «Как стать Full Stack разработчиком», а также недавно «Какие навыки необходимы хорошему разработчику» Эта статья покрывает небольшое количество вопросов, однако проводя последнее время большое количество технических интервью, сталкивался с тем, что даже люди с большим опытом могут путаться в них либо не знать каких-то нюансов самого языка C#. Думаю эта статья может быть полезна как новичкам, так и опытным разработчикам.
Базовый C#
Expression Tree и его отличие от делегата
Что такое делегат ? Экземпляр делегата — это инстанс класса, инкапсулирующий ссылку на метод. Делегаты — это безопасный способ производить вызов методов, цепочек методов, передавать ссылки на методы конкретных инстансов либо же на статические методы. В C# есть три основных типа делегатов : Func, Action и более устаревший Predicate. С появлением Action & Func уже отпала необходимость в своих типах делегатов. Но довольно часто можно увидеть подобные конструкции, особенно при работе с O/RM над разными источниками данных:
Expression<Func<int, bool>> exprTree = num => num < 5;
Мы видим, что это все тот же анонимный делегат, созданный с помощью лямбда синтаксиса, принимающий int параметр и возвращающий bool, но сам делегат является generic-параметром класса Expression Tree. Expression Trees / Expression / Дерево выражений — это механизм, который предоставляет API для работы с кодом как с метаданными. Вообще в C# существует множество механизмов работы с кодом из кода, кроме деревьев выражений, например:
- Reflection — позволяет смотреть на код как на метаданные, производить динамические операции вызова, искать нужные методы и классы.
- Reflection Emits — позволяет генерировать код на лету, напрямую используя промежуточный язык CLR — MSIL (IL)
- CodeDOM — предшественник Roslyn, способ скомпилировать куски кода на ходу.
- Roslyn — API для кодогенерации и динамической компиляции кода.
Разницу между CodeDOM & Roslyn можно почитать тут. Возвращаясь к деревьям выражений — основная идея в том, что вы можете, как на ходу собрать кусок кода, руководствуясь какой-то логикой, так и проанализировать ранее собранный Expression, преобразовав его, например, в язык запросов к источнику данных (так работает Entity Framework). Также вы можете скомпилировать его и запустить на исполнение. Так вот, запись в форме Expression сразу создает дерево выражений на основе кода, который является анонимным делегатом. Кстати, деревья выражений иммутабельные, следовательно, к примеру для операций объединения предикатов через OR или AND — вам потребуется создать новое дерево, на основе двух старых. Эту проблему решают через ExpressionVisitor. Одна из лучших статей, которую я видел по этой теме.
Разница между Делегатом и событием (event)
Ключевое слово event ни что иное, как синтаксический сахар, позволяющий добавить дополнительное поведение свойству типа делегата. Проблема, которую синтаксически решают события — это сделать так, чтобы мы не могли случайно переопределить всю цепочку вызовов методов, добавленных к переменной типа делегата. Вы можете использовать псевдо методы (почти аналог геттера и сеттера) add & remove, за исключением что event будет оберткой вокруг переменной типа делегата. Также, если нам не нужны псевдо-методы add & remove ключевое слово event не позволяет сделать присвоение делегата извне класса, тем самым переписав всю цепочку вызовов.
Разница между локальными функциями и делегатами
Более подробно про отличия локальных функций и делегатов можете найти в моей предыдущей статье. Локальную функцию можно определить в конце метода, переменную делегата только перед использованием. Главное, что нужно понимать, локальная функция не является делегатом и не преобразуется в делегат во время использования, следовательно, не будет потреблять ресурсы в управляемой куче. В целом, локальные функции и делегаты имеют разные сферы применения. Локальные функции более уместны для выделения заново используемого куска кода внутри конкретного метода.
Какой LINQ синтаксис лучше использовать ?
В C# есть два возможных синтаксиса использования LINQ:
- Method chain, т.е. Цепочка методов
- SQL Like, т.е. Псевдо sql код.
Лично я сторонник использования цепочки методов в большинстве случаев. Но некоторые выражения возможно реализовать только в SQL-like форме, а именно: использование let выражений. Так же можно имплементировать LEFT join & CROSS join в обеих формах, вопрос вкуса и читаемости кода:
Лично мне кажется, что sql-like форма тут более лаконична и понятна. В иных случаях, мне кажется, более целесообразно использовать форму записи цепочки методов.
Зачем нужны Properties & Fields
Property (свойство) — используется как внешний контракт класса Field (поле) — как приватный член класса. Интерфейсы синтаксически позволяют работать только со свойствами, так же объявляя для них псевдо методы get & set. Свойства могут быть virtual & override, но вот управлять приватностью псевдо методов в переопределенном свойстве в наследнике не выйдет.
Разница Readonly и const модификаторов
Const — на этапе компиляции производит inline подстановку значения. Readonly же защищает поле от изменения после выполнения конструктора. Следовательно, для того, чтобы значение из const подставилось в другие сборки — необходима их перекомпиляция.
Чем IQueryable отличается от IEnumerable
Чисто синтаксически — ничем, это одинаковые по сигнатуре интерфейсы. Но наличие интерфейса IQueryable у некоторого объекта подразумевает наличие, например extension методов, реализующих удаленный запрос к данным, используя этот объект. При работе с LINQ в Entity Framework, если вы напишите что-то вроде
//в переменной db лежит объект типа DbContext IEnumerable customers = db.Customers; var findJohn = customers.Where(p => p.Name.Contains(“John”)).ToList();
То метод Where — будет взят из linq to entities, т.е. Произойдет вызов в базу данных, затем фильтрация. Если же вы используете Var customers = db.Customers.AsQueryable();
, то на IQueryable будет вызван extension-метод, который может построить дерево выражений и вернуть его для вызова другого extension-метода ToList, где будет запущен код вызова в базу данных
Тонкости работы с null
По-умолчанию в C# < 8 версии либо с выключенным nullable reference type (C# 8) обычному ссылочному типу можно присвоить значение null на этапе компиляции. Далее, мы имеем такие средства работы с null: 1. Оператор условного возврата null. var variable = someObj?.Prop?.Value;
В случае если у someObj Prop = null
, мы не получим исключение, а просто вернем null. Эта конструкция аналогична
if(someObj != null && someObj.Prop != null){ variable = someObj.Prop.Value; } else { variable = null; }
2. Оператор поглощения null
var variable = someObj.Prop ?? new PropClass();
Тут в случае Prop = null мы поглотим это значение с помощью создания нового инстанса PropClass. 3. Оператор условной инициализации В случае если значение переменной null, мы производим инициализацию, в противном случае — нет. Например someList[0] ??= new PropClass();
выполнит присвоение первому элементу листа значением нового инстанса, если там был null. Довольно удобно пользоваться конструкциями, вроде a ?? b ?? c
, либо d ??= e ??= f
Код выше будет исполнен как:
a ?? (b ?? c) d ??= (e ??= f)
Но все же советую использовать скобки для приоритетов операций, и избежания путаницы. Кроме того, операторы ?? и ??= могут быть перегружены для класса, что я с 99% вероятностью не рекомендую делать просто так :) 4. Ключевое слово default. Для ссылочных типов является синонимом null. Для значимых — пустого значения. Является более универсальным. 5. В случае использования тернарного оператора (condition ? expression1 : expression2) если в одном из экспрешенов возвращается null, то оба блока должны быть nullable. Ранее нам была доступна только структура для Nullable, которая могла сделать из ValueType переменную, которая может хранить еще и Null. Далее был создан синтаксический сахар для структуры Nullable, как оператор «?». В C#8 мы получили Nullable reference types. Эта фича может быть включена как для проекта целиком так и для конкретного куска кода. Польза от этой фичи в том, что вам не нужно боятся что где-то будет null, если вы его туда не присваивали. Ранее это решалось библиотеками вроде Optional, это была своеобразная обертка над возможным null в функциональном стиле. Если в моделях вашего контроллера есть Non-nullable значение, то в случае отсутствия его в запросе — будет выброшено исключение валидации модели.
Работа с исключениями
Рассмотрим вопрос — чем отличается проброс исключения из блока catch через Throw и throw e :
catch (Exception ex) { throw; // Сохраняет оригинальный стек вызова throw new MyException("failed", ex); // оборачивает старое исключение в новое throw new MyException("failed"); // заменяет исключение throw ex; // Очищает оригинальный стек вызова }
Также реализовать поведение, схожее с
catch (Exception ex){ throw; }
сохраняя стек оригинального вызова, можно использовав класс ExceptionDispatchInfo, делая это в любом месте:
ExceptionDispatchInfo exceptionDispatchInfo = null; try { // Код который вызывает Exception тут } catch (Exception ex) { exceptionDispatchInfo = ExceptionDispatchInfo.Capture(ex); } // Тут какая-то логика if (exceptionDispatchInfo != null) { exceptionDispatchInfo.Throw(); }
Более подробно тут.
Тонкости работы с датой и временем
Работа с датой и временем всегда боль. Почти на каждом зрелом проекте я встречал баги, связанные с часовыми поясами или неправильной интерпретацией времени. Лично мое мнение (и не только), что хранение времени вместе с тайм зоной в классе DateTime чревато ошибками. Гораздо удобней хранить его либо в DateTimeOffset либо в Long, как Unix time, т.е. хранение даты и времени и работа с временем в целом в приложении должны быть отвязаны от часовых поясов (UTC), и они должны накладываться при необходимости в нашей бизнес-логике.
Ко и Контр — вариантность в C#
Ко и контр вариантность — это возможность производить неявное преобразование обобщенных параметров, в обобщенных делегатах и интерфейсах. Т.к. Ключевое слово out указывает, что на месте параметра T может быть сам класс так и его родительские классы, как в сигнатуре интерфейса IEnumerable:
public interface IEnumerable : IEnumerable { IEnumerator GetEnumerator(); }
Для ключевого слова in — наоборот, сам класс и его производные. Для демонстрации приведу такой кусок кода, где все сразу станет понятно:
interface ISome<out OParam, in IParam>{ } class InParam { } class OutParamParent { } class InParamDerived : InParam { } class OutParam : OutParamParent { } class Some<OParam, IParam>: ISome<OParam, IParam> { } private void SomeMethod() { ISome<OutParamParent, InParamDerived> some = new Some<OutParam, InParam>(); }
Если бы не ключевые слова in & out в интерфейсе ISome — неявное преобразование типов не работало бы.
Default interface implementation vs Abstract class
В C# 8 реализовали возможность иметь реализацию по-умолчанию в интерфейсах. Сравнивать эту фичу языка с абстрактными классами чисто по возможностям не совсем корректно. Сделано это было для обратной совместимости, например какая-то система реализует механизм плагинов, и чтобы не ломать плагины, разработанные сторонними разработчиками (т. е. Не заставлять их имплементировать новые члены интерфейса), заливать новый апдейт, то была добавлена возможность поставлять имплементацию по-умолчанию уже в интерфейсе. Использовать эту фичу не рекомендуется в иных кейсах.
Асинхронное программирование
Разница между Task и Thread
Класс Task является всего лишь моделью, переносящей состояние выполнения куска кода, переданного ей. За стратегию выполнения (т.е. определения потока выполнения) отвечает TaskScheduler, в случае если вы его не укажете явно — будет взят планировщик по-умолчанию (планировщик пула потоков). Класс Thread является объектной оберткой вокруг реального потока операционной системы, а также методами управления этим потоком. Поток, созданный с помощью инстанцирования класса Thread не будет помещен в пул потоков.
Что отличает вызовы await и .Result
- Await — неблокирующий вызов, отпускающий вызывающий поток, ожидая результат.
- .Result — блокирующий вызов, текущий поток будет удержан до завершения операции и возврата результата Таски.
Как работает Aggregate exception
Aggregate Exception — исключение, оборачивающее исключения в классе Task. Зачем оно было придумано ? Во-первых, задачи (Task) могут комбинироваться, где в результате будет получена новая задача, например методом WhenAll. Так вот, исключения всех участвующих задач будут обернуты в одно исключение AggregateException у задачи-результата этого вызова. Кстати, если вы захотите сделать await такой задачи, то будет выброшено только первое исключение.
Разница между async / await и Task API ContinueWith
Механизм async / await делает из метода стейт-машину под капотом, присоединяя продолжение после неблокирующего ожидания используя контекст синхронизации текущего потока как стратегию. ContinueWith никак не связан с async / await, производит продолжение цепочки тасок, когда таска в коллбеке этого метода завершена по какой-то причине. Кстати, по этой же причине если вы разместите внутри ContinueWith await какого-то метода, то после прерывания на неблокирующее ожидание, будет выполнен следующий ContinueWith, а затем только продолжение первого. Более подробно можно почитать в моей статье.
Вызов ConfigureAwait
Метод ConfigureAwait вызывается на классе TaskAwaiter, тем самым конфигурируя выставление контекста синхронизации для кода, который будет вызван после await. При вызове ConfigureAwait(false) — мы устанавливаем контекст по-умолчанию (контекст тред пула), тем самым прерывая наследование контекста, это было актуально при работе с классическим asp.net, а также в коде своих библиотек. В asp.net core это больше не актуально. Однако, если вы разрабоатываете на любом UI-фреймворке (winforms, wpf) хотите выполнить продолжение метода в пуле потоков (вместо UI потока), то можно вызвать await вместе с ConfigureAwait(false), но по-хорошему так лучше не делать.
Использование CancellationTokenSource
CancellationTokenSource (CTS) — фабрика CancellationToken’ов (CT). CT используется для передачи как параметр в Task при ее запуске, чтобы управлять механизмом согласованной отмены задачи. При вызове операции отмены в CTS (Cancel), мы можем использовать инстанс CT внутри кода таски чтобы проверить
- Была ли отменена операция ? token.IsCancellationRequested, далее реализуя свою логику
- Либо же вызывать время от времени ThrowIfCancellationRequested , который выбросит исключение, если на CTS произошла отмена. Главный нюанс здесь — если токен не был ассоциирован с Task и мы выбросили исключение — статус задачи будет Failed, в случае же ассоциации — статус будет Cancelled.
При работе с долгими запросами можно произвести инъекцию CTS прямо в контроллер, чтобы механизм asp.net в случае разрыва соеденения сам производил отмену в CTS.
Многопоточность
Чем отличаются Monitor, Semaphore и Mutex
Mutext и Semaphore — являются синхронизирующими конструкциями ядра операционной системы, используя объектные обертки в .NET, мы можем координировать кросс-процессные операции. Например, с помощью Mutex вы можете контролировать количество запущенных инстансов приложения. Mutex пропускает только один поток. Semaphore делает то же самое, но позволяет ограниченному числу потоков иметь доступ к ресурсу. Альтернативой Semaphore в .NET является SemaphoreSlim, не являющийся объектом ядра операционной системы. Monitor — объект .NET платформы, в C# используется внутри оператора lock, для синхронизации доступов к управляемым ресурсам.
Порядок в понятиях : Task, TAP и TPL
Множество людей путают Task, TAP и TPL.
- Task — это условно механизм вызова кода и отслеживания его статуса, реализованный с выходом .NET 4.0.
- TAP — Task Asynchronous Pattern — асинхронный паттерн программирования, построенный вокруг механизма тасок.
- TPL — Task Parallel Library — библиотека, использующая таски, но решающая иную задачу : распараллеливание вычислений. Так же мощной составляющей TPL есть DataFlow, реализует модель акторов и дает возможность параллелить вычисления.
Работа CLR
ApplicationDomain в .NET Core
Домены приложения были упразднены с выходом .NET Core, однако, была оставлена некоторая обратная совместимость по обработке ошибок. Сделано это было с целью перекладывания ответственности на изоляцию на более надежные и менее затратные технологии, вроде контейнеризации
.NET Standard и NetCoreApp
С выходом .NET 5 версионирование с помощью .NET Standard было упразднено. Однако, для прошлых версий .NET Core и .NET Framework .NET Standard указывает на совместимости, сделано такое разделение было для того, чтобы разные версии библиотек могли полагаться на различные версии .NET Core & .NET Framework имплементирующие какой-то конкретный .NET Standard (с обратной совместимостью).
Подписанные сборки и строгие имена
Это защитный механизм, для предотвращения возможности поставки измененного кода. На docs.microsoft.com есть достаточно хорошее определение: Сборка со строгим именем создается с помощью закрытого ключа, который соответствует открытому ключу, распространяемому со сборкой, и самой сборке. Сборка включает манифест сборки, который содержит имена и хэши всех файлов, из которых состоит сборка. Сборки с одинаковым строгим именем должны быть идентичны.
InternalsVisibleToAttribute
Для того, чтобы иметь возможность преодолеть ограничения модификаторов доступа, например, если нам нужно будет сослаться в тестовой сборке на internal класс — можно использовать атрибут InternalsVisibleToAttribute
.NET 5
В .NET 5 было добавлено довольно много нововведений в работу самой CLR, например:
- Single file apps — убрана необходимость распаковки во временную директорию.
- App trimming — Tree Shaking для .NET сборки.
- Улучшение производительности работы кода
- Улучшение производительности сборки мусора
- Изменения в JIT компиляции и новый режим Ready to run
- Изменения в многопоточной работе
И много чего еще. Вот неплохой доклад на эту тему.
В заключение
Если вы начинающий .NET разработчик, вступайте в мое Telegram комьюнити, если же вы опытный синьор разработчик/техлидер, можете написать мне на фейсбук, я добавлю вас в закрытый Telegram канал, для обмена опытом и обсуждения сложных технических вопросов.
46 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів