February 17

Разбираем средний уровень C#

Вероятно каждый разработчик C# хоть раз да натыкался на технические тесты при трудоустройстве, где необходимо в прямом смысле «на лету» проверить свою компетенцию в языке и смежных технологиях. Нередко такие тесты представляют собой подборку каверзных вопросов, затрагивающих не только синтаксис, но и «подводные камни» C#: поведение итераторов, специфику перегрузок, нюансы перегонки типов (conversion), работу с исключениями и многое другое.

Ключевая цель подобных проверочных заданий — выяснить, насколько кандидат способен ориентироваться в языке «на интуитивном уровне» и при этом не упускать важных деталей.

Важно понимать, что форматы тестов бывают разные: от онлайн-викторины с вариантами ответов до полноценного hands-on задания с реальным проектом. Но суть у них одна: проверить понимание языка и умение решать прикладные задачи, опираясь на базовые и углублённые знания. Текущий «мини-тест», разобранный ниже, как раз пример того, что может ожидать C#-разработчика на позиции «среднего уровня», где уже требуется чёткое понимание не только синтаксических конструкций, но и принципов работы CLR, нюансов типов и особенностей фреймворка .NET.

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

Вопрос 1. «В чём разница между типами var и dynamic?»

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

  1. «Тип var изменяемый, тогда как тип dynamic неизменяемый»
  2. «Тип var определяется во время компиляции, тогда как тип dynamic — во время выполнения»
  3. «dynamic — устаревшее название типа var»
  4. «Тип var определяется во время выполнения, тогда как тип dynamic — во время компиляции»
  5. «Тип dynamic изменяемый, тогда как тип var неизменяемый»

Обоснование:
В C# ключевое слово var используется для статического (компиляторного) вывода типа: реальный тип переменной становится известен уже на этапе компиляции, и дальнейшая работа идёт именно с ним (например, если var x = 10; то компилятор понимает, что x — это int).

dynamic, напротив, был введён для динамической модели: компилятор пропускает большинство проверок, а фактическое разрешение методов и свойств, а также проверка доступности членов типа, происходит во время выполнения (runtime). Это значит, что при использовании dynamic вы получаете гибкость «позднего связывания», но теряете ряд проверок при компиляции.

Таким образом, ключевое отличие:

  • var → тип выводится на этапе компиляции (статический вывод типа).
  • dynamic → тип обрабатывается при выполнении программы (динамическая диспетчеризация).

Выбранный ответ: 2. «Тип var определяется во время компиляции, тогда как тип dynamic — во время выполнения».

Вопрос 2. «Что необходимо вставить вместо пропуска в код ниже, чтобы сделать класс Person неизменяемым?»

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

  1. Abstract
  2. Record
  3. Internal
  4. Private
  5. Virtual

Обоснование:
Начиная с C# 9, ключевое слово record позволяет создавать так называемые record types, которые в сочетании со свойствами с init-аксессорами позволяют сделать объекты неизменяемыми (immutable). При использовании record вместо class, компилятор генерирует ряд дополнительных методов (например, для сравнения значений), и, самое главное, в сочетании со свойствами вида { get; init; } мы получаем «неизменяемую» структуру данных: состояние объекта задаётся при создании и в дальнейшем не меняется.

Остальные варианты никак не гарантируют неизменность:

  • abstract — делает класс абстрактным, но не препятствует изменению данных.
  • internal — ограничивает область видимости типа, но не делает объект неизменяемым.
  • private — недопустимо как модификатор класса в данном контексте (можно было бы только ограничить доступ к конструктору или членам).
  • virtual — позволяет переопределять члены класса в производных типах, но не имеет отношения к неизменности.

Таким образом, чтобы создать неизменяемый тип, в данном случае следует использовать record.

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

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

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

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

Обоснование:

  • После вызова Select(x => x / 2) на исходной последовательности { 1, 2, 3, 4, 5, 6, 7 } при целочисленном делении получится новая последовательность:
0, 1, 1, 2, 2, 3, 3
  • Далее Where(x => x == 2) выберет из неё все элементы, равные 2, то есть два значения: 2, 2.
  • Метод Single() выбрасывает исключение, если в последовательности больше или меньше одного элемента. Здесь элементов два, значит во время выполнения возникнет InvalidOperationException.

Отсюда следует, что результатом работы кода будет ошибка при выполнении программы.

Выбранный ответ: 5. «Ошибка выполнения (Runtime error)».

Вопрос 4. «Какой аргумент необходимо вставить на место пропуска “__” в первой строке кода ниже, чтобы найти в директории все файлы с расширением .log?»

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

  1. "*.log"
  2. ".log"
  3. "?log"
  4. "? .log"
  5. "log"

Обоснование:
Метод Directory.EnumerateFiles (как и GetFiles) в качестве второго параметра принимает шаблон поиска (search pattern) в стиле масок Windows. Чтобы получить все файлы с расширением .log, применяется шаблон "*.log".

  • Символ * заменяет любое количество произвольных символов в имени файла.
  • ".log" в конце указывает на файлы с расширением .log.

Другие варианты (".log", "?log", и т. п.) либо не будут корректно находить все нужные файлы, либо не подходят к синтаксису поискового шаблона.

Выбранный ответ: 1. "*.log".

Вопрос 5. «Что произойдёт с файлом file при выполнении данного кода?»

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

  1. «Открывается файл. Если файл не существует, вызывается исключение. Файл будет открыт как для чтения, так и для записи»
  2. «Если файл существует, он перезаписывается. Файл открывается только для записи»
  3. «Создаётся новый файл. Если такой файл уже существует, приложение выбрасывает ошибку»
  4. «Если файл существует, текст добавляется в конец файла»
  5. «Создаётся новый файл. Если такой файл уже существует, он перезаписывается»

Обоснование:

  • При использовании FileMode.Open метод File.Open(...) пытается открыть уже существующий файл. Если файл не существует, выбрасывается исключение (FileNotFoundException).
  • Поскольку в данном случае мы вызываем перегрузку File.Open(path, FileMode), доступ (по умолчанию) устанавливается как чтение-запись (FileAccess.ReadWrite), а расшаривание файла не разрешается (FileShare.None).

Остальные варианты либо описывают создание нового файла (FileMode.Create, FileMode.CreateNew, FileMode.OpenOrCreate или FileMode.Append), либо перезапись/добавление, что не соответствует FileMode.Open.

Выбранный ответ: 1. «Открывается файл. Если файл не существует, вызывается исключение. Файл будет открыт как для чтения, так и для записи».

Вопрос 6. «Какое из перечисленных преобразований типов может привести к потере точности?»

Варианты:

  1. ulong → double
  2. sbyte → decimal
  3. char → decimal
  4. int → double
  5. byte → ushort

Обоснование:

  1. ulong → double. Тип double (64-битный IEEE 754) может не сохранить точность больших целых чисел, особенно если ulong превышает 253−12^53 - 1253−1. Формально при любом неявном или явном преобразовании беззнакового 64-битного типа в double возможно потерять точность.
  2. sbyte → decimal. Здесь нет потери точности, поскольку decimal хранит целые значения вплоть до очень больших границ и легко вмещает диапазон sbyte.
  3. char → decimal. Числовое значение char (код символа) тоже прекрасно умещается в decimal без потери точности.
  4. int → double. Все 32-битные целые (включая 231−12^{31}-1231−1) могут быть представлены в 64-битном формате double точно (без дробной части), поскольку double гарантированно точно хранит целые значения до 253−12^{53}-1253−1.
  5. byte → ushort. Здесь тоже нет потерь, поскольку byte (0..255) легко помещается в ushort (0..65535).

Таким образом, единственное преобразование, которое может приводить к потере точности, — это из ulong в double.

Выбранный ответ: «ulong num = 123UL; double value = num;».

Вопрос 7. «В ходе разработки вашего приложения вы заметили, что используемый вами Nuget-пакет обновился в репозитории — появилась новая версия пакета X: 2.2.44. За что отвечает выделенный элемент версии пакета 2.2.44?»

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

  1. Предварительная версия с обратной совместимостью
  2. Критические изменения без обратной совместимости
  3. Исправление ошибок с обратной совместимостью
  4. Критические изменения с обратной совместимостью
  5. Новые функции с обратной совместимостью

Обоснование:

В принятой схеме семантического версионирования (SemVer) номер пакета обычно указывается как Major.Minor.Patch, где:

  • Major (первое число) отвечает за крупные изменения, часто ломающие обратную совместимость.
  • Minor (второе число) предназначен для добавления новых функций, обычно не ломая совместимость.
  • Patch (третье число) используется для исправлений ошибок (bug fixes) и незначительных изменений, не затрагивающих обратную совместимость.

В версии 2.2.44 выделенный элемент — это 44, то есть Patch.
Соответственно, повышение patch-версии означает выпуск исправлений, которые сохраняют совместимость с предыдущими версиями (нет «ломающих» изменений).

Выбранный ответ: 3. «Исправление ошибок с обратной совместимостью»

Вопрос 8. «Вкакой строке кода ниже допущена ошибка?»

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

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

Разбор кода и где именно «ошибка»

В данном примере метод GetPersonnel является итератором, поскольку он возвращает IEnumerable<Person> и использует yield return. По правилам C# в итераторном методе:

  • Разрешены операторы yield return (возврат очередного элемента) и yield break (завершение итерации).
  • Разрешён и «пустой» return;, но только начиная с C# 2.0 это эквивалентно «yield break;» (полный выход из итератора).

Однако ряд учебных материалов и некоторые тестовые среды до сих пор указывают на то, что в итераторе нужно явно использовать yield break;, а не «простой» return;. В старых версиях C# такой «пустой» return; мог вызывать ошибку компиляции, а иногда это рассматривают как стилистическую «ошибку» (путает читающих код).

Если ориентироваться настрогое правило «в итераторе использовать только yield break; для остановки», то строка 4 c return; считается неправильной. С точки зрения современных версий языка это не является синтаксической ошибкой — компилятор её допускает и трактует как «полностью завершить итерацию», но зачастую в тестовых/учебных вопросах именно строка с return; внутри yield-метода помечается как «ошибка».

Выбранный ответ: «Ошибка» считается допущенной в 4 строке (оператор return; внутри итераторного метода). В строгой учебной постановке там нужно использовать yield break;

Вопрос 9. «Событие, которое может привести кошибке, возникает редко. Выберите верное утверждение обобработке исключений вэтом случае.»

Варианты:

  1. «Выбор метода обработки не скажется на производительности»
  2. «Проверку условия можно игнорировать»
  3. «Обработку исключения можно игнорировать»
  4. «Использование try/catch будет оптимальным выбором»
  5. «Проверка условия будет оптимальным выбором»

Обоснование

При решении подобных вопросов обычно исходят из того, что:

  • Если «ошибочное» событие может возникать часто, то большое число выбрасываний исключений (throw) отрицательно скажется на производительности. Тогда имеет смысл заранее проверять условие, чтобы избежать «дорогих» исключений.
  • Если «ошибочное» событие случается очень редко (а «нормальный» путь выполнения — самый распространённый), то постоянные «проверки на всякий случай» могут быть менее эффективными, чем единичный (редкий) перехват исключения. В таком случае использовать try/catch считается вполне обоснованным и зачастую оптимальным решением.

Таким образом, если вероятность ошибки мала, код выгоднее писать «в лоб», а в случае действительно редкого эксцесса перехватывать исключение.

Выбранный ответ: «Использование try/catch будет оптимальным выбором».

Вопрос 10. «Какой из следующих вариантов ограничивает параметр обобщённого типа объектом класса, реализующего интерфейс IEnumerable?»

Варианты:

  1. class Processor<T> where T: class:IEnumerable
  2. class Processor<T> where T: IEnumerable
  3. class Processor<T> T: interface IEnumerable
  4. class Processor<T> with T: IEnumerable
  5. class Processor<T> where T:new()

Обоснование

В C# при задании множественных ограничений на параметр обобщённого типа (например, «должен быть ссылочным типом (class)» и одновременно «реализовать интерфейс IEnumerable») используется синтаксис:

То есть через запятую перечисляются несколько ограничений.

  • Вариант 1 (class Processor<T> where T: class:IEnumerable) судя по контексту задания — это опечатка или условная запись, которая должна означать where T : class, IEnumerable. Именно такая комбинация «class, IEnumerable» как раз говорит, что T является классом, реализующим IEnumerable.
  • Вариант 2 (where T: IEnumerable) не ограничивает T быть обязательно классом — T может быть и структурой, которая реализует IEnumerable.
  • Остальные варианты либо неверны синтаксически, либо не имеют отношения к требуемым ограничениям (например, where T: new() требует лишь наличие конструктора без параметров, но ничего не говорит о наследовании IEnumerable).

Таким образом, из предложенного набора именно первый вариант (при условии, что мы читаем «class:IEnumerable» как опечатку вместо «class, IEnumerable») соответствует требованию «ограничить T объектом класса, реализующего IEnumerable».

Выбранный ответ: 1. class Processor<T> where T: class:IEnumerable

Вопрос 11. «Какое ключевое слово необходимо использовать, чтобы компилятор не создавал автоматически методы доступа add и remove для событий, оставив их реализацию наусмотрение производного класса?»

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

  1. static
  2. abstract
  3. virtual
  4. sealed
  5. extern

Обоснование

Чтобы у события не генерировались автоматически методы доступа add и remove и чтобы их реализация была отложена для производных классов, событие в базовом классе должно быть abstract. Тогда в производном классе программист обязан явно описать add и remove.

  • static, sealed, extern здесь не имеют отношения к событию без реализации.
  • virtual событие будет иметь стандартную реализацию, которую можно переопределить в наследниках, но методы add/remove всё же будут сгенерированы.
  • Только abstract обязывает производный класс предоставить собственную реализацию методов доступа.

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

Вопрос 12. «В ходе разработки системы распределения заказов в службе доставки маркетплейса вам необходимо реализовать метод, который получает числовой идентификатор заказа и возвращает строковый GUID этого заказа. Какой делегат лучше всего описывает такой метод?»

Варианты:

  1. Action<string, int>
  2. Action<int, string>
  3. Func<string, int>
  4. Func<int, string>
  5. Event<int, string>

Обоснование

  • Делегаты вида Action<...> не имеют возвращаемого значения, а в нашем сценарии метод возвращает GUID (строку).
  • Делегаты вида Func<TIn, TResult> имеют возвращаемое значение. Первые параметры — входные аргументы, а последний параметр Func — тип возвращаемого значения.
  • Нам нужен делегат, который принимает числовой (int) идентификатор и возвращает строку (string). Это точно соответствует Func<int, string>.

Выбранный ответ: 4. Func<int, string>.

Заключение

Как мы видим, тесты на собеседованиях (особенно на позицию C#-программиста уровня Middle) нередко комбинируют фундаментальные и «острые» вопросы, чтобы максимально оценить всесторонние навыки кандидата. Важно не только выучить ответы заранее, но и понимать мотив и механику за каждым вопросом. Ведь в реальном проекте придётся ежедневно сталкиваться с похожими ситуациями — от правильной обработки исключений до грамотного выбора типов и контроля версий.

Поэтому лучший совет: не заучивайте вопросы, а углубляйтесь в логику и практику языка. Пробуйте разные сценарии, смотрите, где компилятор выдаёт предупреждения, как проявляются исключения, осваивайте новые фишки C# (рекорды, паттерн-матчинг, интеграцию с системами сборки). Именно тогда любые тесты перестанут быть «испытанием» и превратятся в логичный финальный этап, подтверждающий вашу квалификацию.

А если вдруг в вас закрадётся ощущение, что вы подзабыли что-то из современных возможностей C# — вернитесь к подобным «мини-тестам», освежите теорию и повторите практику. Такой подход позволит всегда держать язык «в тонусе» и уверенно чувствовать себя на будущих собеседованиях.