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

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

Надеемся, что наша подборка будет для вас полезна, поможет освежить ваши знания или просто улыбнет.

image

Пример 1


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

Наш первый пример – пример на внимательность и на знание того, во что разворачивается блок using. А также вполне себе тема для общения во время собеседования.

Рассмотрим код:

    public struct SDummy : IDisposable
    {
        private bool _dispose;
        
        public void Dispose()
        {
            _dispose = true;
        }

        public bool GetDispose()
        {
            return _dispose;
        }

        static void Main(string[] args)
        {
            var d = new SDummy();

            using (d)
            {
                Console.WriteLine(d.GetDispose());
            }

            Console.WriteLine(d.GetDispose());
        }
    }

Что выведет на консоль метод Main?
Обратим внимание, что SDummy – это структура, реализующая интерфейс IDisposable, благодаря чему переменные типа SDummy можно использовать в блоке using.

Согласно MSDN оператор using во время компиляции разворачивается в try-finally блок. Поскольку структура не может быть null, наш using развернется в следующий код:

    try
    {
        Console.WriteLine(d.GetDispose());
    }
    finally
    {
        ((IDisposable)d).Dispose();
    }

Итак, в нашем коде внутри блока using вызывается метод GetDispose(), который возвращает булевское поле _dispose, значение которого для объекта d еще нигде не было задано (оно задается только в методе Dispose(), который еще не был вызван) и поэтому возвращается значение по умолчанию, равное False. Что дальше?

Заметим, что в строке:

((IDisposable)d).Dispose();

происходит упаковка (boxing) переменной d, т.к. SDummy – это значимый тип (value type) (о подробностях можно почитать, например, тут).

А это означает, что метод Dispose вызывается уже для другого объекта, а вовсе не для объекта d! Соответственно, значение у поля _dispose для объекта d не поменяется и второй раз на консоль также будет выведено False.

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

Пример 2


Конструкторы и последовательность их вызовов – одна из основных тем любого объектно-ориентированного языка программирования. Иногда такая последовательность вызовов может удивить и, что гораздо хуже, даже «завалить» программу в самый неожиданный момент.

Итак, рассмотрим класс MyLogger:

class MyLogger
{
    static MyLogger innerInstance = new MyLogger();

    static MyLogger()
    {
        Console.WriteLine("Static Logger Constructor");
    }

    private MyLogger()
    {
        Console.WriteLine("Instance Logger Constructor");
    }

    public static MyLogger Instance { get { return innerInstance; } }
}

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

Посмотрим, что есть в нашем классе MyLogger:

  1. Задан статический конструктор
  2. Есть приватный конструктор без параметров
  3. Определена закрытая статическая переменная innerInstance
  4. И есть открытое статическое свойство Instance для общения с внешним миром

Для простоты анализа этого примера внутри конструкторов класса мы добавили простой вывод на консоль.

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

class Program
{
    public static void Main()
    {
        var logger = MyLogger.Instance;
    }
}

Что выведет эта программа?
Все мы знаем, что статический конструктор вызывается перед доступом к любому члену класса (за исключением констант). При этом запускается он единственный раз в рамках application domain.

В нашем случае мы обращаемся к члену класса — свойству Instance, что должно вызвать сначала запуск статического конструктора, а потом вызовет конструктор экземпляра класса. Т.е. программа выведет:

Static Logger Constructor
Instance Logger Constructor


Однако после запуска программы мы получим на консоли:

Instance Logger Constructor
Static Logger Constructor


Как так? Инстанс конструктор отработал раньше статического конструктора?!?
Ответ: Да!

И вот почему.

В стандарте ECMA-334 языка C# на счет статических классов указано следующее:

17.4.5.1: «If a static constructor (§17.11) exists in the class, execution of the static field initializers occurs immediately prior to executing that static constructor.

17.11: … If a class contains any static fields with initializers, those initializers are executed in textual order immediately prior to executing the static constructor


(Что в вольном переводе значит: если в классе есть статический конструктор, то запуск инициализации статических полей происходит непосредственно ПЕРЕД запуском статического конструктора.

Если класс содержит какие-либо статические поля с инициализаторами, то такие инициализаторы запускаются в порядке следования в тексте программы непосредственно ПЕРЕД запуском статического конструктора.)

В нашем случае статическое поле innerInstance объявлено вместе с инициализатором, в качестве которого выступает конструктор экземпляра класса. Согласно стандарту ECMA инициализатор должен быть вызван ПЕРЕД вызовом статического конструктора. Что и происходит в нашей программе: конструктор экземпляра, являясь инициализатором статического поля, вызывается ДО статического конструктора. Согласитесь, довольно неожиданно.

Обратим внимание, что это верно только для инициализаторов статических полей. В общем случае статический конструктор вызывается ПЕРЕД вызовом конструктора экземпляра класса.

Как, например, тут:

class MyLogger
{
    static MyLogger()
    {
        Console.WriteLine("Static Logger Constructor");
    }

    public MyLogger()
    {
        Console.WriteLine("Instance Logger Constructor");
    }
}

class Program
{
    public static void Main()
    {
        var logger = new MyLogger();
    }
}

И программа ожидаемо выведет на консоль:

Static Logger Constructor
Instance Logger Constructor


image

Пример 3


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

Пусть нам необходимо реализовать такую функцию, которая проверяет число на нечетность (т.е. что число не делится на 2 без остатка).

Реализация может выглядеть так:

static bool isOddNumber(int i)
{
    return (i % 2 == 1);
}

На первый взгляд все хорошо и, например, для чисел 5,7 и 11 мы ожидаемо получаем True.

А что вернет функция isOddNumber(-5)?
-5 нечетное число, но в качестве ответа нашей функции мы получим False!
Разберемся, в чем причина.

Согласно MSDN про оператор остатка от деления % написано следующее:
«Для целочисленных операндов результатом a % b является значение, произведенное a — (a / b) * b»
В нашем случае для a=-5, b=2 мы получаем:
-5 % 2 = (-5) — ((-5) / 2) * 2 = -5 + 4 = -1
Но -1 всегда не равно 1, что объясняет наш результат False.

Оператор % чувствителен к знаку операндов. Поэтому, чтобы не получать таких «сюрпризов», лучше результат сравнивать с нулем, у которого нет знака:

static bool isOddNumber(int i)
{
    return (i % 2 != 0);
}

Или завести отдельную функцию проверки на четность и реализовать логику через нее:

static bool isEvenNumber(int i)
{
    return (i % 2 == 0);
}

static bool isOddNumber(int i)
{
    return !isEvenNumber(i);
}

?

Пример 4


Все, кто программировал на C#, наверняка встречался с LINQ, на котором так удобно работать с коллекциями, создавая запросы, фильтруя и агрегируя данные…

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

А пока рассмотрим небольшой пример:

int[] dataArray = new int[] { 0, 1, 2, 3, 4, 5 };

int summResult = 0;
var selectedData = dataArray.Select(
    x =>
    {
        summResult += x;
        return x;
    });

Console.WriteLine(summResult);

Что выведет этот код?
Мы получим на экране значение переменной summResult, которое равно начальному значению, т.е. 0.

Почему так произошло?

А потому, что определение LINQ запроса и запуск этого запроса – это две операции, которые выполняются отдельно. Таким образом, определение запроса еще не означает его запуск/выполнение.

Переменная summResult используется внутри анонимного делегата в методе Select: последовательно перебираются элементы массива dataArray и прибавляются к переменной summResult.

Можно предположить, что наш код напечатает сумму элементов массива dataArray. Но LINQ работает не так.

Рассмотрим переменную selectedData. Ключевое слово var – это «синтаксический сахар», который во многих случаях сокращает размер кода программы и улучшает ее читабельность. А настоящий тип переменной selectedData реализует интерфейс IEnumerable. Т.е. наш код выглядит так:

    IEnumerable<int> selectedData = dataArray.Select(
    x =>
    {
        summResult += x;
        return x;
    }); 

Здесь мы определяем запрос (Query), но сам запрос не запускаем. Схожим образом можно работать с базой данных, задавая SQL запрос в виде строки, но для получения результата обращаясь к базе данных и запуская этот запрос в явном виде.

То есть пока мы только задали запрос, но не запустили его. Вот почему значение переменной summResult осталось без изменений. А запустить запрос можно, например, с помощью методов ToArray, ToList или ToDictionary:

int[] dataArray = new int[] { 0, 1, 2, 3, 4, 5 };

int summResult = 0;

// определяем запрос и сохраняем его в переменной selectedData
IEnumerable<int> selectedData = dataArray.Select(
    x =>
    {
        summResult += x;
        return x;
    });

// запускаем запрос selectedData
selectedData.ToArray();

// печатаем значение переменной summResult
Console.WriteLine(summResult);

Этот код уже выведет на экран значение переменной summResult, равное сумме всех элементов массива dataArray, равное 15.

С этим разобрались. А что тогда выведет на экран эта программа?

int[] dataArray = new int[] { 0, 1, 2, 3, 4, 5 }; //1
var summResult = dataArray.Sum() + dataArray.Skip(3).Take(2).Sum(); //2
var groupedData = dataArray.GroupBy(x => x).Select(  //3
    x =>
    {
        summResult += x.Key;
        return x.Key;
    });

Console.WriteLine(summResult); //4

Переменная groupedData (строка 3) на самом деле реализует интерфейс IEnumerable и по сути определяет запрос к источнику данных dataArray. А это значит, что для работы анонимного делегата, который изменяет значение переменной summResult, этот запрос должен быть запущен явно. Но такого запуска в нашей программе нет. Поэтому значение переменной summResult будет изменено только в строке 2, а все остальное мы можем не рассматривать в наших вычислениях.

Тогда нетрудно посчитать значение переменной summResult, которое равно, соответственно, 15 + 7, т.е. 22.

Пример 5


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

Несмотря на то, что он вряд ли является показательным с точки зрения определения уровня разработчика, этот пример встречался нам в нескольких различных тестах. Возможно, его используют за универсальность, потому что он отрабатывает одинаково как на C и C++, так и на C# и Java.

Итак, пусть есть строка кода:

int i = (int)+(char)-(int)+(long)-1;

Чему будет равно значение переменной i?
Ответ: 1

Можно подумать, что здесь используется численная арифметика над размерами каждого типа в байтах, поскольку для преобразования типов тут довольно неожиданно встречаются знаки «+» и «-».

Известно, что в C# тип integer имеет размер 4 байта, long – 8, char – 2.

Тогда легко подумать, что наша строка кода будет равносильна следующему арифметическому выражению:

int i = (4)+(2)-(4)+(8)-1;

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

int i = (int)+(char)-(int)+(long)-sizeof(int);

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

        int i =
        (int)( // call explicit operator int(char), i.e. char to int
          +( // call unary operator +
            (char)( // call explicit operator char(int), i.e. int to char
              -( // call unary operator -
                (int)( // call explicit operator int(long), i.e. long to int
                  +( // call unary operator +
                    (long)( // call explicit operator long(int), i.e. int to long
                      -1
                    )
                  )
                )
              )
            )
          )
        );


image

Заинтересовало Обучение в Veeam Academy?


Сейчас идет набор на весенний интенсив по C# в Санкт-Петербурге, и мы приглашаем всех желающих пройти онлайн-тестирование на сайте Veeam Academy.

Курс стартует 18 февраля 2019 г., продлится до середины мая и будет, как всегда, совершенно бесплатным. Регистрация для всех, кто хочет пройти входное тестирование, уже доступна на сайте Академии: academy.veeam.ru

image

Комментарии (24)


  1. Naglec
    08.02.2019 17:52

    В чем польза от этих примеров?


  1. MonkAlex
    08.02.2019 20:12

    Все примеры, кроме может быть Linq — какие то бесполезные.
    Особенно растроила структура с IDisposable — структуры и так непростые к применению, так надо было добавить ещё.


  1. SergeyT
    08.02.2019 23:45
    +21

    Объяснение к первому примеру — неверное. Дело не в том, что в блоке finally происходит упаковка, а в том, что компилятор создает "защитную" копию переменной, поскольку переменные в блоке 'using' очень похожи на readonly-поля (ее нельзя переприсвоить) и компилятор пытается гарантировать "неизменяемость" состояния.


    Посмотрите во что компилятор разворачивает код:


    SDummy sDummy = default(SDummy);
            SDummy sDummy2 = sDummy;
            try
            {
                Console.WriteLine(sDummy.GetDispose());
            }
            finally
            {
                ((IDisposable)sDummy2).Dispose();
            }
            Console.WriteLine(sDummy.GetDispose());

    И упаковки — не просиходит в этом коде вообще! И это очень, повторюсь, очень важно. Если бы она была, то енумераторы в коллекциях не были бы структурами (какой смысл делать их структурами, если блок 'using', который генерирует компилятор для foreach все равно приведет к упаковке?


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


    Потом нужно объяснять семантическую разницу между классами и структурами и говорить о том, что структуры должны быть неизменяемыми (должны быть readonly struct). Если структура изменяемая, то компилятор генерирует огромную кучу защитных компий, которые совершенно не очевидны никому в этом мире, кроме пары десятков человек. А эти копии могут а) негативно повлиять на производительность (тут идет напоминание, что структуры — это оптимизация) и б) вы можете очень легко отстрелить себе ногу, поскольку изменение состояние произойдет на компии.


    Ну и использовать подобные вещи в качестве загадок — это очень, имхо, плохая практика. Число профиссиональных разработчиков (а не задротов) знают подобные ньюансы единицы. В буквальном смысле.


    Это очень advanced знания, которые нужны либо а) энтузиастам-задротам или б) людям, которые разрабатываеют очень высоконагруженные приложения на .net.


    1. SergeyT
      08.02.2019 23:51
      +10

      Да, и хочется добавить.

      Вообще, меня очень смущает первый вопрос. Он показывает, что автор этого вопроса сам не совсем понимает, что происходит у дот-нетика под капотом.

      Упаковка и защитные копии структур — это две очень сильно разные вещи. Упоковки приводят к засорению кучи и влияют на производительность засчет притормаживания сборки мусора (добиться которого в простом Ынтырпрайз приложении, кстати, очень сложно).

      Защитные же копии — это другой зверь. Их и было много, а с появлением 'in' модфикаторов и ref-return-ов стало еще больше. И я бы рассчитывал, что в голове у автора подобных вопросов эти два кейса лежат на разных полках, поскольку они приводят к разным проблемам, по разному проявляются, по разному ищутся и по разному решаются.


      1. Leopotam
        09.02.2019 12:12

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


        1. DistortNeo
          09.02.2019 13:43

          Там не всё так просто. Да, вы можете передавать структуры по ссылке, но если у компилятора не будет 100% уверенности, что операция не приведёт к изменению структуры, то будет создана её копия, причём без всяких предупреждений.


          Если вы не используете вложенные структуры, не используете дженерики, не вызываете у структуры методы, то передача по ссылке in будет оправдана. В остальных случаях нужно быть предельно аккуратным, чтобы не угробить производительность.


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


          1. Leopotam
            09.02.2019 13:48

            Ну генерики в структах вообще зло и должны быть запрещены из-за непредсказуемых аллокаций на ровном месте. Вложенные структуры — да, не использую, надо будет обновить тесты.


            1. DistortNeo
              09.02.2019 15:31
              -1

              Идите нафик с подобными предложениями и не мешайте писать высокопроизводительный код. Генерики в сочетании со структурами хороши тем, что JIT-компилятор для каждой комбинации параметров генерирует свой код. Если что-то позволяет выстрелить в ногу, то это не означает, что от этого нужно отказываться. Я ещё, о ужас, иногда использую System.Runtime.CompilerServices.Unsafe.


              1. Leopotam
                09.02.2019 15:46
                -1

                Ансейф я и сам использую. Проблема как раз в неконтролируемых аллокациях памяти в довольно неожиданных местах:
                jacksondunstan.com/articles/3453
                jacksondunstan.com/articles/3468

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

                Надеюсь, что никогда не придется пользоваться подобным «высокопроизводительным» кодом.


                1. DistortNeo
                  09.02.2019 16:14
                  -1

                  Это проблема не с генерик-структурами, а с конкретной кривой реализацией JIT-компилятора или среды выполнения. Конкретно в том случае использовался Mono довольно старой версии.


                  Я много времени потратил на сравнение производительности в совершенно различных сценариях и не заметил абсолютно никаких проблем что в MS .NET, что в Mono последних версий.


                  Хотя не, немного вру: у меня есть пример кода, где в случае использования структуры T выражения new T() и default(T) оказывались неэквивалетными. Но там совсем непотребство творилось. Пост, что ли, запилить...


                  1. Leopotam
                    09.02.2019 16:21
                    -1

                    Это проблема не с генерик-структурами, а с конкретной кривой реализацией JIT-компилятора или среды выполнения.

                    «У меня все работает» ©. Игнорирование потенциально возможных проблем на разных рантаймах — это не решение. Раньше подобное приходилось слышать про mono.
                    не заметил абсолютно никаких проблем

                    Отличный аргумент. Но это не значит, что их там нет.


                    1. DistortNeo
                      09.02.2019 16:40
                      -1

                      То есть вместо того, чтобы поправить баг в Mono или поспособствовать решению проблемы (завести тикет и подробно описать в нём баг), вы предлагаете запретить криво работающий в нём функционал, сделав плохо всем. Отличная идея!


                      1. Leopotam
                        09.02.2019 16:42
                        -1

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


                        1. DistortNeo
                          09.02.2019 16:56
                          -1

                          Отлично. Запретите тогда тот же KeyValuePair<,>.


                          1. Leopotam
                            09.02.2019 17:00
                            -1

                            Штатная правильная реализация != «ухты, мы теперь можем и так!» По ссылкам выше как раз показаны особенности, обходя которые, можно использовать данный функционал. Другое дело — кто будет разбираться в этих тонкостях в общем случае? Работает и ладно.


                            1. DistortNeo
                              09.02.2019 17:04
                              -1

                              И как же вы их обойдёте для KeyValuePair<,>, где его нельзя сконстрировать напрямую через поля, а можно только через конструктор?


                              1. Leopotam
                                09.02.2019 17:16
                                -1

                                По идее нельзя отдавать такой конструктор в паблик и нужно правильно собирать его в энумераторе.


                                1. DistortNeo
                                  09.02.2019 17:32
                                  -1

                                  То есть отрезать ногу, когда она болит?

                                  Представьте себе, этот тип ещё и в пользовательском коде можно конструировать и использовать. И это далеко не единственный тип генерик-структуры, который используется. Разработчики ещё всякие ValueTuple, ValueTask выкатывают.


                                  1. Leopotam
                                    09.02.2019 17:40

                                    У меня специализация — мобильный геймдев, процессинг данных в рантайме без фризов на сборку мусора, т.е нулевые аллокации в основном цикле апдейта, причем на несравнимо более дохлом железе, чем десктоп. Кейсы с выделением памяти всегда выносятся на этап инициализации и не допускаются в процессинге (и не потому, что рантайм такое не позволяет). Где общая производительность и непрерывность по времени исполнения не важна — там допускаю использование всего, чего душа пожелает.


        1. SergeyT
          10.02.2019 21:46
          +1

          Нет, все сложнее. Полное описание можно найти здесь: The ‘in’-modifier and the readonly structs in C#.

          Если коротко, то in-модификатор очень похож на readonly поля: такие параметры нельзя переприсвоить и для структур компилятор генерирует защитные копии для обращений к свофствам и методам, чтобы обеспечить неизменяемость. А вот эти защитные копии и будут ломать нам ноги, поскольку они не явные.


      1. Oxoron
        09.02.2019 20:00

        Вообще, меня очень смущает первый вопрос. Он показывает, что автор этого вопроса сам не совсем понимает, что происходит у дот-нетика под капотом.

        Это нормально.
        Я когда придумываю задания, часто ошибаюсь с ответом. Тут главное, проверить ответ. И поблагодарить человека, если он дал более полное объяснение.


      1. Loxmatiymamont
        09.02.2019 21:19
        -2

        SergeyT Спасибо за комментарий и полезные замечания.
        Действительно, в объяснении первого примера допущена ошибка.
        ?И? ?м?ы? ?у?ж?е? ?у?в?о?л?и?л?и? ?е?г?о? ?а?в?т?о?р?а? :)

        Вы правы. Действительно, компилятор разворачивает блок using для значимых типов в другую конструкцию, нарушая спецификацию языка C# ради оптимизации (согласно комментариям Э. Липперта https://stackoverflow.com/questions/2412981/if-my-struct-implements-idisposable-will-it-be-boxed-when-used-in-a-using-statem).
        Таким образом, упаковки (boxing) не происходит, однако создается скрытая копия переменной.
        Подробности с описанием очень похожего примера можно найти у Эрика Липперта (https://ericlippert.com/2011/03/14/to-box-or-not-to-box/)
        Общий вывод такой, что изменяемые значимые типы (mutable value types) — это зло, которое лучше избегать.

        Соответствующие изменения в статью постараемся внести в ближайшее время.

        Мы особенно рады, что этот пост не оставил равнодушным автора одной из наиболее популярных книг нашей библиотеки (https://habr.com/ru/company/veeam/blog/417691/).


        1. SergeyT
          10.02.2019 21:58

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


          И снова я с этим не совсем согласен. Эрик, ведь, — жук, да он пишет вот что: «You’d be perfectly justified in thinking that there is a boxing performed in the finally because that’s what the spec says.». Но ведь он пишет лишь то, что «да, в спеке есть пример, который говорит, что using блок выворачивается в каст», но спека не говорит, что каст там должен быть.

          Спека (вот здесь, например) вообще хитро написана, там есть вот что:

          An implementation is permitted to implement a given using-statement differently, e.g. for performance reasons, as long as the behavior is consistent with the above expansion.


          Так что никто никого не нарушает;), не стоит спешить с выводами.


  1. An70ni
    09.02.2019 13:59

    А во втором примере если поле вынести в другой класс, то последовательность так же отработает?