February 19

Продвинутый уровень C#

Любой разработчик, который выходит на уровень «C# продвинутый», понимает, что стандартного знания синтаксиса и базовых конструкций уже недостаточно для решения сложных задач. Сегодня работодатели всё чаще хотят видеть человека, умеющего разбираться в глубинных механизмах платформы .NET: как работает сборщик мусора на низком уровне, в чём тонкости async/await и рефлексии, какие проблемы возникают в многопоточном коде и как их эффективно решать.

Подобные тесты на собеседовании проверяют не только общий кругозор, но и вашу способность мыслить системно: от понимания внутреннего устройства платформы до грамотного применения паттернов проектирования. Успешно пройденный тест — не просто формальная галочка в резюме, а залог того, что вы сможете поддерживать и развивать сложную инфраструктуру, заниматься оптимизацией и глубокой отладкой. Если вы знаете, где именно кроются подводные камни C#, умеете управлять потоками, обыгрывать нюансы обобщений и понимать жизненный цикл объектов в CLR, то прохождение подобных тестов станет для вас лишь формальной процедурой.

📌Навигация по материалам в Telegram

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

Вопрос 1. «Что выведет данный код?

Варианты ответов:

  1. Ошибка выполнения (Runtime error)
  2. Kate
  3. Sasha
  4. Ошибка компиляции (Compile-time error)
  5. Ошибка индекса (Index error)

Обоснование

Начиная с C# 8 появился тип Index, позволяющий использовать оператор ^ (индекс с конца).

  • ^1 означает «последний элемент»
  • ^2 означает «предпоследний элемент» и т. д.

В массиве people из пяти элементов индексы распределены так:

  • 0: "Tanya"
  • 1: "Sasha"
  • 2: "Ivan"
  • 3: "Kate"
  • 4: "Anya"

Соответственно,

  • people[^1] → "Anya",
  • people[^2] → "Kate".

Следовательно, строка string selected2 = people[myIndex2];, где myIndex2 = ^2;, вернёт "Kate". Ошибок компиляции и выполнения нет, поскольку индекс корректен.

Выбранный ответ: «Kate»

Вопрос 2. «Перед вами реализация класса, который поддерживает сортировку. В какой строке кода можно внести улучшение для обработки возможного значения null?

Варианты ответа:

  1. 1-я строка
  2. 2-я строка
  3. 3-я строка
  4. 4-я строка
  5. 5-я строка

Обоснование

Метод CompareTo (реализация интерфейса IComparable) обычно учитывает случай, когда переданный объект может быть null. По стандарту .NET любой ненулевой объект считается больше null. Правильная обработка предполагает, что если obj == null, мы сразу возвращаем положительное число (например, 1), указывая, что текущий объект «больше» пустой ссылки.

Наиболее логично вставить проверку null непосредственно перед проверкой типа (if (obj is Balance example)). То есть в той же строке, где начинается проверка — 3-я строка: мы можем сначала сделать if (obj == null) return 1;, а уж затем идти к паттерн-матчингу на Balance.

Таким образом, «улучшение для обработки null» следует реализовать в 3-й строке (где располагается if (obj is Balance example)), дополнив её дополнительной проверкой obj == null.

Выбранный ответ: 3-я строка.

Вопрос 3. «У вас есть коллекция list, реализующая интерфейс IReadOnlyList. Вам необходимо проверить, содержит ли коллекция значения, и выбрать первый и последний элементы. Какой из следующих способов наиболее производителен для данной задачи?»

Варианты кода для проверки:

1.

2.

3.

4.

5.

Обоснование

  • Методы Any(), First(), Last() из LINQ могут добавлять косвенные проверки и вызовы внутри себя (хотя в случае IList или IReadOnlyList они обычно оптимизированы, но всё же это отдельные вызовы).
  • list.Any() всё равно будет проверять, действительно ли коллекция пуста, в то время как list.Count > 0 при реализации IReadOnlyList обычно даёт прямой доступ к количеству элементов.
  • Доступ к элементам по индексу (list[0], list[list.Count - 1]) напрямую извлекает значения, минуя вспомогательные механизмы LINQ.

Таким образом, самый эффективный способ — это способ № 2:

Здесь мы совершаем единственный вызов list.Count и два прямых индексных доступа к элементам. Всё прочее работает через LINQ-расширения, которые могут иметь небольшие издержки.

Выбранныйответ:

if (list.Count > 0)

{

var firstValue = list[0];

var lastValue = list[list.Count - 1];

}

Вопрос 4. «Что выведет следующий код?

var newFile = Path.GetTempFileName();

Console.WriteLine(Path.GetExtension(quot;{newFile}"));

Варианты ответов:

  1. .TEMP
  2. .tmp
  3. .t
  4. Пустая строка
  5. Ошибка выполнения

Обоснование

Метод Path.GetTempFileName() создаёт во временной папке ОС уникальный пустой файл и возвращает к нему полный путь. По умолчанию файл получает расширение ".tmp". При вызове Path.GetExtension будет извлечена часть строки начиная с последней точки до конца, то есть .tmp.

Ошибки выполнения или компиляции здесь быть не должно, а пустая строка не возвращается, так как в имени файла явно присутствует расширение.

Выбранный ответ: «.tmp»

Вопрос 5. «Что выведет код ниже?

internal class Member

{

[JsonIgnore(Condition = JsonIgnoreCondition.Never)]

public int Id { get; set; }

[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]

public string Name { get; set; }

[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]

public string Description { get; set; }

public void Save()

{

JsonSerializerOptions options = new()

{

DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault

};

Console.WriteLine(JsonSerializer.Serialize(this, options));

}

}

// Вызов

new Member() { Id = 5686529, Name = default, Description = null }.Save();

Варианты ответа:

  1. {"Id":5686529, "Name":""}
  2. {"Id":5686529, Description:null}
  3. {"Id":5686529}
  4. {"Id":5686529, "Name":"", Description:null}
  5. {"Name":"", Description:null}

Обоснование

1. Свойство Id помечено [JsonIgnore(Condition = JsonIgnoreCondition.Never)], следовательно оно никогда не игнорируется и всегда попадает в сериализованный JSON.

2. Свойство Name помечено [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)], а также в глобальных настройках DefaultIgnoreCondition = WhenWritingDefault. Для строк «значение по умолчанию» — это null. В коде записано Name = default, что эквивалентно null для строк.

  • Раз «Name» равно null, и действует WhenWritingDefault, то оно не будет записано в итоговый JSON.

3. Свойство Description помечено [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)], а мы действительно присваиваем ему null. Соответственно при сериализации это свойство тоже игнорируется.

В результате остаётся только свойство Id со значением 5686529.

Выбранный ответ: {"Id":5686529}

Вопрос 6. «Сколько исключений может возникнуть в представленном коде?

var mainNumber = 0xaffd;

var number = mainNumber ^ (0xff << 4);

number ^= 0xa << 3 * 4;

number ^= 13;

Console.WriteLine(0xfff / number);

string[] names = { "Dog", "Cat", "Fish" };

Object[] objs = (Object[])names;

Object obj = (Object)13;

objs[2] = obj;

for (int i = 0; i < 5; i++)

{

Console.WriteLine(objs[i]);

}

Варианты ответов:

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5

Обоснование

Разберём код пошагово:

  1. Переменная number становится равной нулю, потому что:
  • number = mainNumber ^ (0xff << 4);
  • number ^= 0xa << (3 * 4);
  • number ^= 13;
    По факту после этих XOR-операций значение получается 0, что приводит к делению на ноль в строчке

Console.WriteLine(0xfff / number);

  1. Это гарантированно вызовет DivideByZeroException (1-я возможная ошибка).
  2. Сохранение obj = 13 в массив objs.
    Переменная objs ссылается на тот же массив, что и names, но тип массива на самом деле — string[]. Поскольку пытаются записать int (упакованный в Object) в ячейку, где ожидаются строки, это может вызвать ArrayTypeMismatchException (2-я возможная ошибка).
    Произойдёт это только в случае, если код дошёл до objs[2] = obj; (т. е. если первый DivideByZeroException обработан или каким-то образом не произошёл).
  3. Цикл for (int i = 0; i < 5; i++) при размере массива 3.
    Доступ к objs[3] и objs[4] приведёт к IndexOutOfRangeException (3-я возможная ошибка), если код продолжится и дойдёт до этих индексов.

Таким образом, всего существует три потенциальных исключения, каждое из которых может проявиться, если предыдущие не прервут исполнение программы (например, при условии их перехвата в try/catch).

Выбранный ответ: «3».

Вопрос 7. «Когда T будет заменён конкретным типом в следующей программе?

class MyClass<T>

{

// ...

}

Варианты ответов:

  1. Во время выполнения (Runtime)
  2. Во время отладки
  3. Во время компиляции
  4. Перед компиляцией
  5. Во время работы линтера

Обоснование

В C# при использовании обобщений (generics) фактический тип-параметр (например, int вместо T) фиксируется и проверяется компилятором ещё на этапе компиляции с точки зрения корректности (сопоставления ограничений, соответствия сигнатур и т. д.). Однако в итоговой сборке (IL-коде) сохраняется информация о том, что класс обобщённый (MyClass<T>) и какие реальные типы передаются (MyClass<int>), — это называется рефикацией (reification).

Полноценная «подстановка» (specialization) в смысле создания конкретных машинных инструкций под int, string, и т. д. происходит именно при выполнении (конкретнее — в момент JIT-компиляции) для структурных типов или при необходимости. Тем не менее, в большинстве объяснений про generics подчёркивается, что C# не «размножает» код на этапе компиляции, как это делают шаблоны в C++, а динамически обрабатывает обобщения во время выполнения CLR.

Таким образом, обычно на подобный вопрос отвечают, что в C# «T подставляется (reified) именно во время выполнения» — CLR «знает», с каким конкретным типом мы имеем дело. Если же речь идёт о «проверке» корректности использования T (условия where T : ...), то эта часть действительно делается на этапе компиляции. Но сам механизм «замены T конкретным типом» (создание специализированного кода) с точки зрения среды .NET происходит в рантайме.

Выбранный ответ: «Во время выполнения (Runtime)»

Вопрос 8. «Выберите код из представленных ниже, который создаёт обобщение MyGenericClass с двумя параметрами K и T, где:

  1. K должен быть структурой и реализовать интерфейс IDrawable;
  2. T должен быть ссылочным типом (допускающим или не допускающим значение null), иметь стандартный конструктор и реализовывать обобщённый интерфейс IComparable<T>.»

Варианты:

1.

2.

3.

4.

5.

Обоснование

  • Требование к K: «должен быть структурой и расширять (реализовывать) интерфейс IDrawable». Если сам IDrawable не обобщён, то пишем просто where K : struct, IDrawable.
  • Требование к T: «должен быть ссылочным типом (допускающим или не допускающим null), иметь конструктор по умолчанию и реализовывать обобщённый интерфейс IComparable<T>». В новых аннотациях C# для «ссылочного типа, который может быть и null, и не null» используют синтаксис class?. Также обязателен new(), и обязательно обобщённое IComparable<T> (а не просто IComparable).

Среди предложенных вариантов только № 2 полностью удовлетворяет обоим пунктам:

where K : struct, IDrawable

where T : class?, IComparable<T>, new()

  • K : struct, IDrawable — структура, реализующая IDrawable.
  • T : class?, IComparable<T>, new() — ссылочный тип (включая возможность быть nullable), реализует обобщённый IComparable<T>, имеет конструктор без параметров.

Выбранный ответ:

public class MyGenericClass<K, T>

where K : struct, IDrawable

where T : class?, IComparable<T>, new()

Вопрос 9. «В следующем классе объявлен делегат и событие. Как правильно добавить обработчик события DisplayMessage для объекта класса product?

public class Product

{

public delegate void ProductHandler(string message);

public event ProductHandler? Notify;

public Product(int sum) => Sum = sum;

public int Sum { get; private set; }

public void Put(int sum)

{

Sum += sum;

Notify?.Invoke(quot;На склад поступило: {sum}");

}

}

// ...

// Каким образом подписаться на событие Notify?

Варианты ответа:

  1. product.Notify *= DisplayMessage;
  2. product.Notify.Add(DisplayMessage);
  3. product.Notify ?= DisplayMessage;
  4. product.Notify += DisplayMessage;
  5. product.Notify.Update(DisplayMessage);

Обоснование

В C# стандартный способ подписаться на событие — использовать оператор +=, то есть:

product.Notify += DisplayMessage;

Остальные варианты либо не являются допустимым синтаксисом (*=, .Add(...), .Update(...)) для событий, либо не соответствуют тому, как обычно обрабатываются события (?= — недопустимое сочетание для подписки на делегат события).

Выбранный ответ: product.Notify += DisplayMessage;

Вопрос 10. «Что такое race condition в многопоточном программировании?»

Варианты ответов:

  1. Проблема, возникающая при неверной координации потоков
  2. Ситуация, когда один поток завершая свою работу раньше другого
  3. Состояние, когда потоки выполняются в неопределённом порядке
  4. Ситуация, когда два или более потоков бесконечно ждут друг друга
  5. Ситуация, когда два потока пытаются одновременно получить доступ к одному и тому же ресурсу

Обоснование

  • Определение race condition: несколько потоков пытаются одновременно обращаться (читать или записывать) к одному ресурсу без должной синхронизации, в результате чего итоговое поведение программы оказывается непредсказуемым.
  • Вариант 4 описывает deadlock (взаимную блокировку), а 2 и 3 — просто вопросы о порядке выполнения или завершения потоков, не обязательно приводящие к ошибке.
  • Вариант 5 прямо указывает на одновременный доступ нескольких потоков к общему ресурсу (памяти, состоянию и т.д.) без надлежащей координации — именно это вызывает race condition.

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

Вопрос 11. «Какой метод необходимо добавить в представленный класс для реализации awaitable-типа?

public class CustomAwaitable : INotifyCompletion

{

private bool _isCompleted;

public CustomAwaitable GetAwaiter() => this;

public bool IsCompleted => _isCompleted;

public void OnCompleted(Action continuation) {

Task.Delay(1000).ContinueWith(t => {

_isCompleted = true;

continuation?.Invoke();

});

}

}

Варианты ответа:

  1. GetTask()
  2. GetResult()
  3. GetState()
  4. OnAwait()
  5. OnCompleted()

Обоснование

Для класса, который может использоваться с оператором await, требуется так называемый Awaiter pattern:

  1. Метод/свойство IsCompleted (определяет, завершена ли операция).
  2. Метод OnCompleted(Action) (для передачи колбэка, вызываемого после завершения).
  3. Метод GetResult() (возвращает результат или выбрасывает исключение, если это необходимо).

В представленном классе уже есть IsCompleted и OnCompleted, а вот GetResult() отсутствует. Этот метод нужен даже если он возвращает void (в случае, когда awaiter ничего не «возвращает»).

Выбранный ответ: Нужно добавить метод GetResult().

Вопрос 12. «Приведён следующий код:

public delegate T GenericDelegate<T>();

public static void Test()

{

GenericDelegate<string> dString = () => "TestCase";

GenericDelegate<object> dObject = dString;

dObject();

Console.WriteLine(RuntimeReflectionExtensions.GetMethodInfo(dObject).ReturnType);

}

Что будет выведено на экран в результате вызова метода Test()?»

Варианты ответа:

  1. System.Object
  2. System.String
  3. TestCase
  4. Ошибка выполнения (runtime error)
  5. Ошибка компиляции (compile-time error)

Обоснование

  • В C# есть ковариантность делегатов по возвращаемому значению: делегат, возвращающий string, может быть «поднят» до делегата, возвращающего object.
  • Однако сам метод, на который указывает ссылка, продолжает иметь сигнатуру с возвращаемым типом string.
  • Вызов

RuntimeReflectionExtensions.GetMethodInfo(dObject).ReturnType

возвращает реальный тип возвращаемого значения в самом методе, а это string.

  • Несмотря на то, что dObject объявлен как GenericDelegate<object>, фактический метод по-прежнему возвращает string и именно это покажет рефлексия.

Выбранный ответ: 2. «System.String»

Вопрос 13. «Какой сегмент кода из приведённых ниже позволяет создать атрибут MyAttribute, который должен применяться только к методу или классу?»

Анализ вариантов

Чтобы ограничить применение атрибута к классам и методам, нужно использовать [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] и унаследовать класс от System.Attribute.

  • Вариант, где написано что-то вроде [AttributeTargets.Class|AttributeTargets.Method] без AttributeUsage — неверен, так как атрибут должен указывать [AttributeUsage(...)].
  • Вариант с AttributeTargets.Constructor не соответствует требованиям «только класс или метод».
  • Вариант, где класс наследуется от AttributeUsage (вместо Attribute), тоже неверен.
  • Нам нужен [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] и public class MyAttribute : Attribute. Этот вариант ограничивает применение атрибута только к классам или методам и корректно наследуется от Attribute.

Выбранный ответ:

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]

public class MyAttribute : Attribute {}

Вопрос 14. «Какой метод из класса GC (garbage collector) позволит убедиться, что сборщик мусора не освободит ресурсы, пока в приложении не завершится длительный процесс?»

Варианты ответа:

  1. RemoveMemoryPressure
  2. Collect()
  3. WaitForFullGCApproach()
  4. SuppressFinalize()
  5. KeepAlive()

Обоснование

  • RemoveMemoryPressure и AddMemoryPressure — служат для информирования GC о больших объёмах неуправляемой памяти, используемой приложением. Не гарантируют «несборку» конкретного объекта.
  • Collect() — заставляет GC попытаться освободить неиспользуемую память. Это обратное действие, а не средство «удержать» объект.
  • WaitForFullGCApproach() — блокирует текущий поток до момента, когда GC сообщит о готовящемся сборке (актуально в Server GC), но не «удерживает» объект от сборки.
  • SuppressFinalize() — запрещает вызывать финализатор для объекта, но не исключает его из сборки, если на него нет живых ссылок.

Метод GC.KeepAlive(object) гарантирует, что вплоть до вызова KeepAlive(...) объект считается «живым» (есть жёсткая ссылка), и сборщик не освободит его ресурсы раньше времени. Это используют, например, в сценариях с небезопасными (unsafe) блоками, когда логическая ссылка на объект может исчезнуть до конца метода.

Таким образом, чтобы «удерживать» объект от сборки до окончания некоторого участка кода (например, длительного процесса), мы вызываем GC.KeepAlive(obj) — и GC не соберёт объект, пока не будет достигнута эта точка.

Выбранный ответ: 5. KeepAlive()

Вопрос 15. «Метод GC.Collect() принудительно запускает сборку мусора. Какой из предложенных вариантов будет оптимальным сценарием его использования?»

Варианты ответа:

  1. Вызывать чаще, чтобы не использовать много памяти
  2. Вызывать во время простоя во избежание паузы в приложении
  3. Не стоит вызывать этот метод, сборщик сам оптимизирует время запуска
  4. Вызывать для поколения 0, чтобы освободить память от элементов списка в интерфейсе
  5. Периодически вызывать для поколения 2 с целью повысить производительность

Обоснование

Большинство источников (включая документацию Microsoft) рекомендуют не вызывать GC.Collect() вручную в обычных сценариях — сборщик мусора достаточно умен, чтобы оптимизировать время запуска самостоятельно. Однако если в программе действительно есть подходящий момент, когда можно «принудительно» выполнить сборку, чтобы не создавать пауз в критичной точке (например, между «уровнями» в игре или во время бездействия приложения), то самое разумное — делать это «во время простоя», когда задержка будет незаметна для пользователя.

  • Вариант (1) «Вызывать чаще» — противоречит принципам производительности, поскольку частые сборки могут тормозить приложение.
  • Вариант (3) «Не стоит вызывать» — это действительно общий совет, но если вопрос прямо спрашивает, какой сценарий оптимален при необходимости использовать GC.Collect(), то наиболее точным ответом будет «вызывать во время простоя».
  • Варианты с указанием поколений (4) и (5) — крайне узкие и редко применимые, в большинстве случаев не дают ожидаемого прироста.

Следовательно, лучшая практика, если сборка действительно нужна вручную, — делать это во время простоя приложения.

Выбранный ответ: 2. «Вызывать во время простоя во избежание паузы в приложении».

Заключение

Помните, что тесты — лишь один из инструментов. Реальный успех достигается, когда вы умеете совместить своё продвинутое знание C# с инженерным подходом: пишете понятный, поддерживаемый код, предусмотрительно проверяете крайние случаи и всегда следите за оптимизацией. Если вы уверены в своей экспертизе и не боитесь «дьявола в деталях», то никакие проверочные задания не станут для вас непреодолимым барьером.

Продолжайте совершенствовать своё мастерство C#, и помните: за каждой сложной задачей всегда есть решение, если подойти к ней достаточно глубоко.