Популярные заблуждения о C#

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

    Та статья представляла из себя практически «идеальную подборку заблуждений в вакууме». Причём они (заблуждения) являются довольно популярными и постоянно встречаются в различных блогах и подборках «99 вопросов для собеседования», «как пройти собеседование на джуниора» или в данном случае «шпаргалка по C#».

    Почему они такие популярные? Я считаю, что потому, что они дают простые и короткие ответы, которые очень удобны для формата теста или квиза. Думать не надо, можно просто запомнить. Практикующие же разработчики повторяют их потому что эти заблуждения часто описывают наблюдаемое положение дел, но это не значит, что они истинны. Если посмотреть в окно в центре города, то можно сделать вывод что все машины — легковые, и это в большинстве случаев будет верно, но к правде имеет довольно слабое отношение.

    Мне хочется, чтобы данная статья была не чисто моим мнением и не очередной компиляцией блогов. Поэтому я проведу аналогию (весьма сомнительную, но мне так захотелось) с юриспруденцией. У нас будет так:

    Закон — ECMA 334 (на данный момент ревизия 5).

    Законопроекты — информация о версиях языка новее, чем в ECMA 334, а также ещё не выпущенных версиях. Эту информацию можно найти на гитхабе dotnet, а так же в статьях-анонсах на MSDN.

    Подзаконные акты — документация (не статьи) MSDN.

    Опыт законоприменения — статьи MSDN, Wikipedia и на других сайтах, информация не абсолютная, требует проверки.

    Прямой опыт — то, что мы можем просто взять и проверить. Тут нам повезло больше, чем юристам, ведь нам не придётся что-то красть, чтобы проверить на сколько лет за это посадят ))

    Не долго думая, возьмём план статьи отсюда и будем рассматривать мифы по списку. Если что-то забуду, добавляйте в комментах.

    Фух... ПОЕХАЛИ!

    Ссылочные и значимые типы (value vs reference types)

    Заблуждение: ссылочные типы (reference type) хранятся в куче (heap), а значимые (value types) — на стэке.

    Почему распространено? Это — простое объяснение, и оно часто тиражируется. Более того, если написать простой метод с простыми переменными одного и другого типа, чем обычно его и иллюстрируют, то всё именно так и будет.

    Закон: во всём стандарте есть только 2 упоминания слова «куча», и это вполне объяснимо, поскольку стэк и куча являются деталями реализации, а не самого языка. Второе упоминание — про то, что зафиксированные (fixed) объекты могут приводить к фрагментации кучи, это нам пока не интересно. А первое упоминание — в разделе 16.1 Structs/General, то есть общем описании, а не определении:

    However, unlike classes, structs are value types and do not require heap allocation

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

    Опыт: действительно, значимые типы, являющиеся локальными переменными в текущей реализации скорее всего окажутся на стэке. Но если мы создадим класс, членом которого является структура, то она, как и все остальные данные этого класса окажется в куче.

    Для классов (ссылочных типов) на текущий момент действительно верно, что во всех простых случаях они окажутся размещёнными в куче. Но ссылочный тип чисто теоретически можно разместить на стэке. Более того, скоро это изменится вполне официально, так же спасибо @VladD-exrabbit за вот эту ссылку: https://github.com/dotnet/runtime/issues/11192

    P.S.: ещё спасибо @PsyHaSTe за ссылки: один, два, три. «Как можно видеть, Липперта эти сравнения бесили ещё в 2009...»

    Так чем же отличаются value и reference типы?

    Читаем стандарт (в нём всё про значимые типы лежит в разделе Structs и слово struct используется для их описания):

    16.4.2 Value semantics

    - A variable of a struct type directly contains the data of the struct, whereas a variable of a class type contains a reference to an object that contains the data…

    Тут текст про то, что структуры содержат в себе сами данные, а переменная со ссылочным типом — только ссылку. А значит структура не может в себе содержать поля, размер которых ещё не известен (в том числе своего же типа):

    struct Node
    {
        int data;
        Node next; // error, Node directly depends on itself
    }
    // is an error because Node contains an instance field of its own type. Another example
    struct A { B b; }
    struct B { C c; }
    struct C { A a; }

    With classes, it is possible for two variables to reference the same object…

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

    Это всё, что описано в разделе про семантику, а для языка это самое главное. Дальше идёт описание конкретно структур (16.4.3 Inheritance) а также свойства, вытекающие из семантики (16.4.4 Assignment — копирование данных при присваивании), 16.4.5 Default values — значение по умолчанию, 16.4.6 Boxing and unboxing — если нам надо передать ссылку, то требуется боксинг. А так же конструкции языка 16.4.7 Meaning of this, 16.4.8 Field initializers,  16.4.9 Constructors, 16.4.10 Static constructors, 16.4.11 Automatically implemented properties.

    На собеседовании на вопрос о различии этих типов главное ответить, что у них разная семантика. Можно, конечно, погрузиться в дальнейшие различия (по списку из стандарта), но нигде среди них нет упоминания, что одно — это то, что идёт на стэк, а другое — в кучу.

    stack vs heap

    Заблуждение (1): стэк быстрый, а куча большая.

    Почему? Потому что мелкие локальные переменные, такие как числа, обычно располагаются на стэке, а жирные объекты размещают в куче. Очевидно, что с мелкими объектами, которые известно где лежат, работать проще и быстрее. Но это скорее следствие, чем причина.

    Закон: слово heap мы уже искали в стандарте и ничего серьёзного не нашли. Слово stack в основном встречается в параграфах про unsafe-блоки и stackalloc, но мы сейчас не про это.

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

    Про размер:

    Размер стэка можно поменять для всего бинарника с помощью EDITBIN.EXE /STACK:<stacksize> file.exe

    А для каждого отдельного потока — через второй аргумент конструктора new Thread().

    По умолчанию, куча действительно больше стэка, но стэк можно увеличить, а куча, хоть и растёт по мере надобности, но не безгранична. Есть куча правил, по которым определяется её размер, так же её можно уменьшить в настройках. Иногда доступная куча оказывается меньше физически доступной памяти  Тогда приходится расчехлять unsafe и делать offheap-аллокации.

    Вывод: размер и того и другого определяются машиной и рантаймом, но не является определяющим признаком.

    Передача по значению / указателю

    Заблуждение:  значимые типы (структуры) передаются по значению а ссылочные (классы) — по ссылке.

    Почему оно популярно? Честно — не знаю. Возможно из курсов Си для начинающих.

    Закон:

    10.2.5 Value parameters
    A parameter declared without a ref or out modifier is a value parameter.

    10.2.6 Reference parameters
    A parameter declared with a ref modifier is a reference parameter.

    10.2.7 Output parameters
    A parameter declared with an out modifier is an output parameter.

    Думаю, тут всё понятно. То, как передаётся объект, определяется не его типом (ссылочный/значимый) а тем, как объявлен и передан аргумент функции. Добавляется ещё странная вещь под названием Output parameter со своей семантикой, но в реализации это такой же ref-параметр, только требующий инициализации в вызываемом методе.  На этом дискуссию можно было бы закончить, но давайте немного займёмся сравнительным языкознанием.

    Для сравнения я возьму Java 8-летней давности (в последний раз что-то значимое на джаве я писал примерно тогда, а с тех пор могло что-то поменяться), С++ (а не C, потому что иначе я сам запутаюсь) и C#. Я хотел тут повторить анализ целиком, но просто приведу ссылки: Java, C++, C#.

    Краткий пересказ: в Java передача только по значению, но есть ссылочные типы и примитивы (это не совсем значимые типы как в C#, но для сравнения сойдёт) В C++ все типы — значимые, но можно передавать как по значению так и по ссылке (и ещё по указателю/адресу). А в C# сочетаются обе эти семантики: можно взять значимый или ссылочный тип и передать любой из них по ссылке или по значению. Это ортогональные понятия и не надо их смешивать.

    P.S.: в новых версиях языка появились in-параметры. С семантической точки зрения они не определяют способ передачи (ведь при запрете изменения объекта нет никакой разницы, как он был передан), но с точки зрения реализации они работают как неизменяемые ref-параметры (readonly ref) и соответственно тоже передаются по ссылке.

    P.P.S.: с out-параметрами тоже не всё так просто. Вот в этой статье есть подробный разбор: https://m.habr.com/ru/company/pvs-studio/blog/542210/, рекомендую к прочтению.

    string — особенный тип

    Заблуждение: ведёт себя как значимый тип, а лежит в куче.

    Закон:

    9.2.5 The string type
    The string type is a sealed class type that inherits directly from object. Instances of the string class represent Unicode character strings.
    Values of the string type can be written as string literals (§7.4.5.6).
    The keyword string is simply an alias for the predefined class System.String

    Как видим, не такой уж он и особенный.

    Опыт: ну да, как и все другие классы (ссылочные типы) строки обычно размещаются в куче. Почему говорят, что он ведёт себя как значимый тип? Я много раз такое слышал, но так и не получил чёткого ответа, почему.

    Про какие особенности речь?

    1.       Это неизменяемый (immutable) и запечатанный (sealed) класс. Это значит, что обычными способами нельзя изменить внутри него данные и нельзя от него унаследоваться. Ну и что? Вы можете создавать классы с такими же ограничениями, ничего особенного.

    2.       Можно сравнивать с помощью оператора==, а обычные структуры нельзя, и для голых классов сравнивается инстанс, но не данные. Ну и что? Для любого своего класса или структуры вы можете написать такой же оператор и они будут вести себя абсолютно так же.

    Более того, иммутабельность строк можно нарушить с помощью небольшой щепотки магии. И да, это уже было на хабре.

    const vs readonly

    Заблуждение:

    ·         const - значение подставляется при компиляции => установить можно только до компиляции

    ·         readonly - установить значение можно только до компиляции или в конструкторе

    Закон:

    12.20 Constant expressions

    A constant expression is an expression that shall be fully evaluated at compile-time

    Не «установить значение», а «значение вычисляется» (это тонкое, но важное различие).

    15.5.3 Readonly fields

    15.5.3.1 General When a field-declaration includes a readonly modifier, the fields introduced by the declaration are readonly fields. Direct assignments to readonly fields can only occur as part of that declaration or in an instance constructor or static constructor in the same class.

    Да, вроде бы похоже. Но почему они в разных разделах (12 и 15)? Давайте посмотрим на название: одно — это выражение, а другое — модификатор поля. И это главное их семантическое отличие.

    Отсюда вытекает важная деталь реализации:

    15.5.3.3 Versioning of constants and static readonly fields
    Constants and readonly fields have different binary versioning semantics. When an expression references a constant, the value of the constant is obtained at compile-time, but when an expression references a readonly field, the value of the field is not obtained until run-time.

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

    Факт: соберите тестовый проект, и откройте его в декомпиляторе. Вы увидите, что вместо констант встанут их значения (поэтому они и являются выражениями), а для readonly-полей останутся на них ссылки.

    Хозяйке на заметку: до .NET Core 3 можно было поменять значение readonly-полей через рефлекшен, начиная же с этой версии такой простой способ больше не работает, но остались другие. Поменять же значения констант, не замаравшись в декомпиляции и рекомпиляции методов, у вас не выйдет.

    ref и out

    Из статьи-«шпаргалки»: ref и out позволяют внутри метода использовать new и для class и для struct

    Факт: см выше (Передача по указателю)

    out тоже что ref, только говорит о том что, метод обязательно пересоздаст переменную

    Тут, наверное, имелось в виду «переназначит», а не пересоздаст, но сильно придираться не будем.

    События, делегаты

    Заблуждение:

    if (Evt != null)
        Evt("hello");

    Закон: 15.8.2 Field-like events

    EventHandler handler = Click;
    if (handler != null)
        handler(this, e);

    Надеюсь, разницу, объяснять не надо.

    P.S.: в новом C# можно не задумываться и писать Evt?.Invoke("hello");

    Finalizer (destructor) ~

    Заблуждение (1): ~Foo() это «деструктор» класса Foo

    Почему? Потому что по той или иной причине сами авторы языка так это называли, хотя вовремя одумались.

    Закон:

    15.13 Finalizers
    [Note: In an earlier version of this standard, what is now referred to as a "finalizer" was called a
    "destructor". Experience has shown that the term "destructor" caused confusion and often resulted to incorrect expectations, especially to programmers knowing C++. In C++, a destructor is called in a determinate manner, whereas, in C#, a finalizer is not. To get determinate behavior from C#, one should use Dispose. end note]

    Причина: деструкторы и финализаторы отличаются семантикой, главное отличие — детерминированность.

    P.S.: в комментах появилась хорошая историческая справка, спасибо @rstm-sf.

    Заблуждение (2): вызывается, когда garbage collector доберется до объекта

    Почему популярно? В большинстве простых случаев это действительно именно так и происходит.

    Закон:

    An instance becomes eligible for finalization when it is no longer possible for any code to use that instance. Execution of the finalizer for the instance may occur at any time after the instance becomes eligible for finalization (§8.9).

    Обращаем внимание, что никаких гарантий нам тут не предоставляют.

    Факт: финализатор будет вызван у объекта, если он у него есть и объект не побывал в методе SuppressFinalize до начала маркировки объектов на финализацию. Где-то между моментом определения что объект недоступен до момента фактического освобождения памяти. Но это не точно. Отменить финализацию можно с помощью метода GC.SuppressFinalize, хотя это может и не сработать. Более того, рекомендованный шаблон реализации IDisposable именно так и поступает.

    Заблуждение (3): вызывается только автоматически средой .Net, нельзя вызвать самостоятельно

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

    myObj.GetType().GetMethod("Finalize",
        BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly)
        .Invoke(myObj, null);

    Конечно, для симуляции корректного поведения следует пройтись по всей цепочке наследования (ведь так написано в стандарте).

    Singleton

    Заблуждение: не забудьте про потокобезопасность и lock

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

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

    Раньше собеседующий ждал тут что-то типа такого кода:

    static Singleton singletonInstance;
    static readonly Object syncRoot = new Object();
    public static Singleton GetInstance()
    {
        if(singletonInstance == null)
            lock(syncRoot)
                if(singletonInstance == null)
                    singletonInstance = new Singleton();
        return singletonInstance;
    }

    Это известный паттерн double checked locking, он нужен для ленивой инициализации. А вопрос, напомню, стоял про синглтон.

    Более того, этот код тоже имеет проблемы. Дело в том, что модель памяти работает не совсем так, как кажется на первый взгляд (это отдельная большая тема, явно не для собеседований уровня джун/миддл). Чтобы её решить, требуется или вставить volatile в нужном месте или аккуратно использовать MemoryBarrier().

    Опыт: ничего из этого на самом деле не требуется для синглтона. Ведь рантайм нам даёт чёткие гарантии о потокобезопасности статических инициализаторов и мы просто можем написать:

    public static Singleton Instance { get; } = new Singleton();

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

    Для другого паттерна, ленивой инициализации, в дотнете есть готовое решение — Lazy<T>, которое использует правильные для данного рантайма примитивы синхронизации.

    И, значит, для оставшегося 1% случаев, когда синглтону нужна гарантированная ленивость, мы можем написать:

    static readonly Lazy<Singleton> lazy = new Lazy<Singleton>(() => new Singleton());
    public static Singleton Instance => lazy.Value;

    И да, это тоже уже было на хабре, с кучей комментов.

    P.S.: и так совпало, что сегодня же на хабре выложили вот такую подробную статью (18+)!


    Вроде, по статье всё. Я явно что-то забыл, или написал неправильно, но для этого есть комменты, да будет срач!

    Комментарии 62

      +11
      Более того, struct может вообще быть выделен не нами и даже не в куче/стеке. Например, можно где-то получить указатель на область памяти, привести его к (SomeStruct*), или ref SomeStruct (чтобы без unsafe) и дальше использовать как обычно. В графическом движке я так делаю с областью, проецируемой в видеопамять. Поэтому такое обобщение в предыдущей статье меня тоже сильно зацепило, но конечно не настолько, чтобы создавать статью-ответ и начинать холивар :)
        +3

        Плюс JIT может тупо оптимизировать struct в значение регистра.

        0
        Дело в том, что модель памяти работает не совсем так, как кажется на первый взгляд

        Модель памяти работает просто: инструкция lock представляет собой full fence, поэтому ни MemoryBarrier, ни volatilte здесь не нужны. Ну а само чтение значения переменной ссылочного типа атомарно.


        Ну и небольшой ликбез по volatile: этот модификатор гарантирует очерёдность операций записи и очерёдность операций чтения. Чтение и запись при этом могут быть переставлены местами.

          0
          А можно ссылку где гарантируется full fence?
          Возможно, мои данные уже устарели, но на текущий момент само MS и в MSDN и в сорцах corert использует lock + volatile…
            0
            А можно ссылку где гарантируется full fence?

            Гуглится по запросу "c# implicit memory barriers"


            но на текущий момент само MS и в MSDN и в сорцах corert использует lock + volatile

            Покажите пример.


            Я вот нашёл:
            https://github.com/dotnet/corert/blob/master/src/System.Private.CoreLib/shared/System/Lazy.cs


            Но там прям в комментариях написано, почему так делается:


            _value = CreateViaDefaultConstructor();
            _state = null; // volatile write, must occur after setting _value

            Или вот:
            https://github.com/dotnet/corert/blob/master/src/System.Private.CoreLib/shared/System/Threading/CancellationTokenSource.cs


            Адская конструкция, зато без lock:


            _state = NotifyingCompleteState; // _state is volatile
            Volatile.Write(ref _executingCallbackId, 0);
            Interlocked.MemoryBarrier(); // for safety, prevent reorderings crossing this point and seeing inconsistent state.

            Ну а если используется lock + volatile, то скорее всего, это значит, что код писали криворукие индусы, и на истину в последней инстанции он явно не претендует.

              0
              github.com/dotnet/corert/blob/master/src/System.Private.CoreLib/shared/System/Lazy.cs

              так ведь тут _state как раз и объявлен volatile:
              private volatile LazyHelper? _state;
                0

                Вы невнимательно прочитали комментарий. Смысл использования volatile — чтобы запись в _state произошла после записи в _value (кстати, в отличие от C++, volatile в C# предотвращает и аппаратный реордеринг). А вот блокировки там нет.

                  0
                    0
                    так что и lock и volatile оба присутствуют и оба используются

                    Неверно. Там не всё под локом сидит. Вот смотрите реализацию Value:


                    public T Value => _state == null ? _value : CreateValue();

                    Дальше смотрите код CreateValue и видите, что функции для части состояний вызываются уже без блокировки:


                    private T CreateValue()
                    {
                        // we have to create a copy of state here, and use the copy exclusively from here on in
                        // so as to ensure thread safety.
                        LazyHelper? state = _state;
                        if (state != null)
                        {
                            switch (state.State)
                            {
                                case LazyState.NoneViaConstructor:
                                    ViaConstructor(); // <-----
                                    break;
                    ...
                      0
                      вы смотрите в неверный режим (там есть 3 режима по 3+4+3 стейта на каждый), нужно смотреть в режим ExecutionAndPublication***
                        0

                        Согласен, принимаю аргумент, использовать Lazy<> без поддержки thread safety как-то странно, когда речь идёт о доступе к нему из разных потоков. Но обратите теперь внимание, что здесь для описания состояния используется не одна переменная, а две: _state и _value. Соответственно, атомарно прочитать состояние уже невозможно.


                        Поэтому и используется volatile, чтобы гарантировать, что значение _state изменится до значения _value. В принципе, можно отказаться от volatile, тогда бы просто пришлось поменять код остальных функций, например:


                        private void ViaConstructor()
                        {
                            _value = CreateViaDefaultConstructor();
                            Thread.MemoryBarrier(); // <-- вот эту строчку надо добавить, если _state не является volatile
                            _state = null;
                        }

                        Но если бы состояние описывалось одним объектом с атомарным доступом, то необходимости в volatile бы не было.

                          0
                          мне кажется, что мы с вами в итоге согласимся ))

                          моя мысль в статье была о том, что нам одного только голого lock не достаточно, и придётся добавить или явный барьер, или volatile.

                          Поскольку в классическом dcl у нас тоже две переменных, то там тоже это надо сделать. Но ещё лучше использовать готовый и протестированный код ферймворка, чем городить свой.
                            0
                            Поскольку в классическом dcl у нас тоже две переменных

                            Можно обойтись и одной для частного случая: когда используется только конструктор без параметров. Правда, в этом случае паттерн с созданием инстанса в статическом конструкторе оказывается ещё эффективнее.

            0

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


            А в паттерне DCL чтение происходит без инструкции lock, отсюда и необходимость использовать volatile.

              +1

              Не согласен.


              Ну прочитает второй поток null после того, как первый поток запишет туда какое-то значение. Ничего страшного, т.к. у нас DCL — второй раз он проверит это значение внутри lock, и справедливость восторжествует. Ну а последующие разы null он прочитать уже не сможет.


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

                +3

                Проблема не в том, что он null может прочитать. Проблема в том, что он может прочитать объект до того как он будет сконструирован полностью.

                  0

                  В смысле, процессор поменяет местами инструкции и в переменную значение запишется раньше? На x86 и x64 такое невозможно. Хотя да, с приходом ARM об этом уже придётся задумываться.

            0
            Отличная статья!

            Можно улучшать, но лучше в подобном стиле излагать другие стандарты и особенности.
            Например про особое отношений компилятора к строкам и зачем появился StringBuilder.
              0
              Честно скажу, я написал эту статью почти сразу после появления оригинальной. Потом мне стало стыдно и я её стёр, не запостив. Но сегодня решил: не пропадать же такой простыне текста ))

              Про строки: у меня просто пропал запал и мне сейчас не интересно это писать. Но вы можете сделать это сами. Даёшь больше качественного контента!
              +2
              Я автор того самого сборника заблуждений. Т.к. данная статья ссылается на мою шпаргалку, вынужден дать комментарий.

              Коротко: в данной заметке в основном уточнения к терминологии. И указывается на то, что ответы на вопросы даны по «текущей реализации» а не терминологически выверено по «текущему стандарту». Эти указания-уточнения в основном полезны. Спасибо автору что нашел время.

              Ниже более развернуто

              Ссылочные и значимые типы (value vs reference types)
              действительно, значимые типы, являющиеся локальными переменными в текущей реализации скорее всего окажутся на стэке.
              Для классов (ссылочных типов) на текущий момент действительно верно, что во всех простых случаях они окажутся размещёнными в куче.

              Если верно — значит не заблуждение, в «текущей реализации» для подавляющего большинства случаев.

              stack vs heap
              Почему? Потому что мелкие локальные переменные, такие как числа, обычно располагаются на стэке, а жирные объекты размещают в куче. Очевидно, что с мелкими объектами, которые известно где лежат, работать проще и быстрее.
              … По умолчанию, куча действительно больше стэка

              Ну вот сами и написали почему говорят что стэк быстрый, а куча большая.
              Это не заблуждение, а констатация реального положения дел.
              Хорошо, что вы указали дополнительные нюансы.

              Передача по значению / указателю
              Про ref и out в шпаргалке есть.
              А говорится так потому, что при передаче refernce type в методе получится переменная указывающая на тот же самый объект.

              string — особенный тип
              Про какие особенности речь?
              1. Это неизменяемый (immutable)
              2. Можно сравнивать с помощью оператора==

              Да про эти особенности. Заголовок можно поменять «string — особенный тип» на «string». Сути дела это не меняет, на собеседованиях спрашивают именно про эти 2 особенности.

              const vs readonly
              Значение константы фиксируется на момент компиляции. А статических readonly-полей (которые часто используют как замену) — на этапе выполнений.
              соберите тестовый проект, и откройте его в декомпиляторе. Вы увидите, что вместо констант встанут их значения

              В шпаргалке ровно это написано, приведен тоже самый пример с компиляцией

              Finalizer (destructor) ~
              Тут чисто термилогический вопрос, не нравится вам что в C# finalizer называют ещё и destructor. Ну это и Microsoft так называет (сами ссылку дали)

              Заблуждение (3): вызывается только автоматически средой .Net, нельзя вызвать самостоятельно

              Хорошо, добавили что через reflection можно. Утверждение, что приватные методы не для того что-бы из вызывали снаружи, ни как не является заблуждением.
              Кстати что говорит «Закон — ECMA 334» по поводу использования reflection.

              Еще раз спасибо автору за критику и дополнения.
                0
                > для подавляющего большинства случаев

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

                > дополнительные нюансы

                это не нюансы, если вы пишете не-тривиальный софт, то бОльшую часть времени он использует данные, а не аллоцирует их

                > Ну это и Microsoft так называет

                прочитайте, что именно Microsoft написали в стандарте. И что им очень за это стыдно
                  +2
                  На мои комментарии там (в шпаргалке) вы не ответили, давайте тут продолжим :)

                  Передача по значению / указателю

                  Вы удивитесь, но все параметры, кроме out и ref, передаются по значению, т.е. копируются в стек. И именно тут ваша шпаргалка вводит неофитов в заблуждение.
                  Вот только для reference типа копируется ссылка, а value тип копируется целиком.
                  И из-за непонимания этих фактов получаются забавные баги:
                  • Многие пытаются в методе изменить объект reference-типа просто присвоим переменной в методе новое значение.
                  • и классика жанра — поменять поле value-типа и удивляться тому, что после вызова метода всё осталось как есть.

                  string — особенный тип

                  вот только вы упорно говорите, что string ведёт себя как value-тип, что совершенно не верно. Ну и про конкатенацию не то пишете. Тип и правда интересный и полезно знать его особенности, но про них вы не пишете.
                    +1
                    классика жанра — поменять поле value-типа и удивляться тому, что после вызова метода всё осталось как есть.

                    Да, вот забавный сценарий:


                    struct Q
                    {
                        public int Value;
                    
                        public void IncrementValue(int value)
                        {
                            Value += value;
                        }
                    }
                    
                    var arr = new[] { 1 };
                    var arr2 = new[] { new Q { Value = 1 } };
                    var list = new List<int> { 1 };
                    var list2 = new List<Q> { new Q { Value = 1 } };
                    
                    arr[0] += 2;
                    arr2[0].IncrementValue(2);
                    list[0] += 2; 
                    list2[0].IncrementValue(2);

                    Угадайте, в каких случаях значение элемента коллекции поменяется.

                      0
                      Угадайте, в каких случаях значение элемента коллекции поменяется.

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


                      var arr = new[] { 1 };
                      arr[0] += 2;

                      Создаётся массив, элементы которого содержат непосредственно инты. Значение элемента поменяется.


                      var arr2 = new[] { new Q { Value = 1 } };
                      arr2[0].IncrementValue(2);

                      Создаётся массив с элементами типа Q. В статье написано, что переменных типа struct хранят непосредственно данные.


                      Поэтому я думаю, что содержимое элемента поменяется.


                      var list = new List<int> { 1 };
                      list[0] += 2; 

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


                      var list2 = new List<Q> { new Q { Value = 1 } };
                      list2[0].IncrementValue(2);

                      Лист объектов типа Q. У одного из элементов вызвывается метод, что не может поменять значение элемента в том случае, если память не выделятеся прямо в листе.


                      Память выделить, как мы видим из примера с массивом, можно, но может быть лист возвращает именно копию, а не ссылку. Я думаю, что решение делать копию или нет, принимает не лист, а вызывающий код, потому что в статье сказано, что переменную можно объявить как ref. Тут переменной нет, поэтому я считаю, что изменится сам элемент.


                      Update: Перечитал статью и понял, что ref относится к параметрам, а не к переменным. Теперь что будет в последнем примере мне неясно. Но всё равно хотелось бы, чтобы менялось значение элемента.

                        +1
                        Тут дело не в параметрах, но близко. В последней строке при получении элемента из листа возвращается копия всей структуры, а сохранения нет (в отличии от предыдущей строки), поэтому значение в листе не изменится.
                          0
                          Тут дело не в параметрах, но близко.

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


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

                            0

                            Можно его и на переменную поставить, и даже на возвращаемое значение. Но оно в классе List не ставится, вот в чём проблема.

                              0
                              Можно его и на переменную поставить, и даже на возвращаемое значение.

                              Точно, можно. Надо было читать документацию.


                              Но оно в классе List не ставится, вот в чём проблема.

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

                                +1

                                Мне кажется, что так сделано по двум причинам:


                                1. Legacy. Изначально никаких ref return не было, и привилегией возврата значения по ссылке при индексации обладали исключительно массивы. И если сейчас, с появлением ref return, изменить логику работы коллекций, то это поломает существующий код.


                                2. При добавлении элементов в List не гарантируется сохранение ссылок на элементы. То есть следующий код:


                                  ref var elem = ref list[0];
                                  list.Add(...);
                                  elem.IncrementValue();

                                  будет некорректным.


                            +2
                            Смотрим какой IL генерит каждая строчка (в пересказе на русский):
                            arr[0] += 2;
                            Грузит в стэк адрес элемента массива, грузит в стэк инт по этому адресу, увеличивает на 2, и сохраняет обратно по адресу

                            arr2[0].IncrementValue(2);
                            Грузит в стэк адрес элемента массива, и вызывает его метод

                            list[0] += 2; 
                            Вызывает геттер, увеличивает полученное значение на 2, вызывает сеттер.

                            list2[0].IncrementValue(2);
                            Вызывает геттер (который возвращает, естественно, копию), вызывает метод этой копии. Более не делает ничего, соответственно копия просто отправляется в страну вечной охоты, исходный лист остаётся неизменным.

                            Итого, первые три строчки меняют значение, четвёртая — нет.

                        +1
                        Finalizer (destructor) ~
                        Тут чисто термилогический вопрос, не нравится вам что в C# finalizer называют ещё и destructor. Ну это и Microsoft так называет (сами ссылку дали)

                        Тоже так считал до того момента, пока мне не подсказали посмотреть в стандарт C++/CLI. Вот что дает ECMA-372, 1st edition, December 2005:


                        1. Лучше расписано в чем заключается различие (8.8.8 Destructors and finalizers).
                        2. Дестркутор вызывает метод Dispose (34.7.13.2 Destructors).

                        К тому же, стандарт CLI говорит о том, что финализатор может вызваться не один раз (I.8.9.6.7 Finalizers — ECMA-335, 6th edition, June 2012).


                        После этого, думаю, не стоит смешивать эти два понятия :)

                        +1
                        string — особенный тип

                        Все верно. Это тип, экземпляры которого имеют произвольный размер. Самому такой не сделать. Как массив.

                          +1
                          Эта особенность принимается, действительно!

                          Но если учесть что реализация System.String содержит в себе кучу unsafe-магии, то мы можем сделать свой класс воспользовавшись той же магией.
                            0

                            Не, там магия на уровне CLR сидит:
                            https://mattwarren.org/2016/05/31/Strings-and-the-CLR-a-Special-Relationship/
                            Придётся слишком глубоко вносить изменения.


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

                              0
                              От CLR-магии нам нужен только резиновый аллокатор, сигнатура которого известна, так что глубоко вносить изменения не придётся ))

                              Либо можно на него вообще забить и юзать AllocHGlobal, но я не уверен, что на это скажет GC увидев ссылку в странное место…
                                0

                                GC занимается перемещением объектов в памяти. Он знает про строки и про массивы. Произвольных объектов произвольного размера в нем не описано, он не сможет их обработать и сломает при переносе.

                                  +1

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

                                    0

                                    Когда GC захочет переместить объект в другое место, сколько байт он должен скопировать?

                                      –1

                                      А об этом он узнает, посмотрев в кучу.

                                        0

                                        И что он в этой куче увидит? Или вы думаете что GC пользуется c/c++ функциями malloc/realloc/free?


                                        Размеры объектов в куче не хранятся. В куче хранится ссылка на тип (точнее на "таблицу методов") объекта, в котором есть его размер.


                                        Вот тут в заголовке класса Object есть целый параграф про его размеры
                                        https://github.com/dotnet/runtime/blob/master/src/coreclr/vm/object.h

                                          0

                                          Спасибо, теперь стало действительно понятнее (сам не смог найти, где
                                          смотреть).


                                          Отдельно доставило (methodtable):


                                              BOOL IsStringOrArray() const
                                              {
                                                  LIMITED_METHOD_DAC_CONTRACT;
                                                  return HasComponentSize();
                                              }
                                          
                                              BOOL IsString()
                                              {
                                                  LIMITED_METHOD_DAC_CONTRACT;
                                                  return HasComponentSize() && !IsArray() && RawGetComponentSize() == 2;
                                              }

                                          То есть в CLR прям жёстко прописано, что хвостатых типов может быть только два.

                            0
                            А если воспринимать строку как массив чаров, то всё в порядке =)
                            Более того, со строкой во многом можно работать как с массивом, это фича. Ну и, реализация IEnumerable дает немножко экстеншенов для работы с перечислениями.
                              0

                              Вот только массив тоже особенный тип и самому такой также не сделать :)

                            +1
                            Заблуждение:

                            if (Evt != null)
                                Evt("hello");
                            


                            Закон: 15.8.2 Field-like events
                            EventHandler handler = Click;
                            if (handler != null)
                                handler(this, e);
                            


                            Товарищи, может кто нибудь прояснить в чем смысл тут?
                              +2

                              Когда-то давно кто-то подумал что все наверняка будут писать многопоточные программы, в которых будут вызывать делегаты и истерично на них подписываться-отписываться безо всякой синхронизации. Вот оттуда эта конструкция и пошла. Типа, между проверкой на null и вызовом делегата другой поток мог успеть отписаться и тогда код свалится по NRE. При этом почему-то умалчивают о том что такой "исправленный" код вовсе и не факт что правильный. В нём делегат вызовут уже после того как произошла отписка. Но совсем не факт что программа вообще готова к такому.

                                0
                                > Типа, между проверкой на null и вызовом делегата другой поток мог успеть отписаться

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

                                > В нём делегат вызовут уже после того как произошла отписка.
                                да, в MSDN так и пишут, что описка не гарантирована
                                  +2
                                  Чтобы попасть между строками, не обязательно истерично подписываться и отписываться.

                                  Чтобы попасть между вот именно теми двумя строками, которые приведены в «заблуждении про делегаты», нужно не меньше двух потоков. И тогда возникает вопрос — а при чём здесь собсна делегаты? То же самое справедливо для любого мутабельного nullable поля. Между проверкой на null и использованием значения может ворваться второй поток и записать туда null.
                              –2
                              Надеюсь, разницу, объяснять не надо.

                              Конечно не надо. Разница следующая. Вот это хороший код:


                              if (Evt != null)
                                  Evt("hello");

                              А вот это код попахивающий и подозрительный:


                              EventHandler handler = Click;
                              if (handler != null)
                                  handler(this, e);

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

                                +1

                                Кстати, про out- параметры.
                                Наткнулся недавно на момент, показавшийся интересным.


                                Оказывается, не для всех out- параметров обязательна инициализация до возврата метода.


                                Может будет интересно — написал про это небольшую статью. Погружение в код Roslyn прилагается. :)

                                  0
                                  Да, статья отличная, уже оценил ) Спасибо!
                                    0

                                    Вам спасибо за оценку. :)

                                  +1

                                  Неплохая статья, правда немного догматичная. Хотя всопнмить мою первую статью на хабр… Там от количества самомнения её в итоге скрыть пришлось и карму обнулять :)


                                  Ссылочные и значимые типы (value vs reference types)

                                  Cамый простой контрпример:


                                  var x = (object)12;
                                  Span<int> y = stackalloc int[12];

                                  инт это структура, но забокшенная она находится в куче, и забоксится может по 547 разных причин. Массивы традиционно являются ссылочными типами, но застакаллоченные лежат на стеке. Можно немного придраться что это массивы не наследующие привычный T[], но позвольте, все не SZArray массивы такие по сути.


                                  Подробнее хорошо написано у Липперта здесь, здесь и здесь (увы, мсдн потер старые статьи, но слава богу у нас есть web.archive). Как можно видеть, Липперта эти сравнения бесили ещё в 2009, так что ничто не ново под луной


                                  stack vs heap

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


                                  Заблуждение: значимые типы (структуры) передаются по значению а ссылочные (классы) — по ссылке.

                                  На разговорном уровне это верно. На менее разговорном — структура состоит из себя самой, а класс — из указателя на данные. Копирование структуры приводит к копии всех данных, копирование указателя — к копии указателя (на те же самые данные). Если представить, как байтики в памяти размещаются, поведение становится совершенно предсказуемым. Тот же вопрос иногда в JS мире задают, тип почему во тут поменялось значение а вот тут — нет. Хотя там никаких структур и нет.


                                  const vs readonly

                                  Забавный факт: в MSIL есть опкоды для загрузки статик полей, а для загрузки констант — нет. То есть в IL вообще не существует никаких констант. Это было для меня в свое время прям раскрывающим глаза открытием.


                                  ref и out

                                  Just don't. Майкрософт наконец сделали юзабельные таплы — используйте их всегда, когда нужно вернуть пару значений, для всяких TryParse(...) используйте нулляблы, и будет всем счастье.


                                  События, делегаты

                                  Как выше верно написали, раньше код мог иногда с наллрефом упасть, а теперь может вызываться код который отписался. Я не могу назвать ни один из вариантов безусловно правильным. Поэтому объявлять его еретическим а второй — исключительно правильным, ведь мсдн разрешает вызывать отписавшиеся делегаты — мне кажется немного самоуверенным.


                                  Заблуждение (1): ~Foo() это «деструктор» класса Foo

                                  Достаточно считать что деструктор в сишарпе это не то же что деструктор в C++ и проблем с терминологией не будет — никаких других деструкторов в языке нет и перепутать не с чем.


                                  Заблуждение: не забудьте про потокобезопасность и lock

                                  Ваш способ самый правильный для синглтона без зависимостей, Если есть зависимости — то лези это хороший выбор, а ещё лучше — просто взять диай и написать AddSingleton<IFoo, Foo>()

                                    +2
                                    Как выше верно написали, раньше код мог иногда с наллрефом упасть, а теперь может вызываться код который отписался.

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


                                    "Ошибку" с вызовом отписанного делегата можно устранить только если делать все три операции (add/remove и invoke) под локом. Но делать так смысла немного.

                                      –2

                                      Я легко могу представить ситуацию где я хочу жесткую ошибку если почему-то код у меня обнулил делегат который кто-то собирался вызывать. Это может означать что где-то есть несинхронизированный доступ.


                                      А во втором варианте имеем молчаливое потенциально некорректное поведение

                                        0

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

                                          –2

                                          Ну я как раз про это. Если кто-то наменял между двумя этими точками содержимое поля — это может сигнализировать об ошибке. Собственно, это не раз ещё Акиньшин лет 5 назад рассказывал на нескольких докладах.


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

                                      0
                                      Спасибо за подробный ответ и за ссылки на Эрика Липперта (добавил в статью)!

                                      С некоторыми пунктами позволю себе не согласиться:

                                      > То есть в IL вообще не существует никаких констант

                                      А как же семейство инструкций ldc.*? а декларации literal?
                                      Возможно, вы имели в виду то, что загрузка констант использует числа (результат вычисления выражений) напрямую, а не ссылаясь на именованный литерал, в отличие от readonly и вообще всех других полей.

                                      >> Заблуждение: значимые типы (структуры) передаются по значению…
                                      > На разговорном уровне это верно… Тот же вопрос иногда в JS мире задают…

                                      В JS всё всегда передаётся по значению (как и в Java), просто там нет двух ортогональных понятий (тип объекта и способ передачи), поэтому к такому относятся более снисходительно. Хотя у меня ни один кандидат, давший такой ответ, собеседование не прошёл.
                                      В C#, поскольку есть две независимых категории, это отличие очень важно. Вы можете передать 4-мя способами: значимый тип по значению, значимый по ссылке, ссылочный по значению, ссылочный по ссылке (указатель на указатель, если сравнивать с плюсами).

                                      >> ref и out
                                      > Just don't.

                                      С тем, что не стоит это использовать без крайней необходимости (напр. PInvoke), соглашусь. Но знать и понимать это надо.

                                      > ведь мсдн разрешает вызывать отписавшиеся делегаты — мне кажется немного самоуверенным.

                                      Ну так он же разрешает. Значит лучше вызвать протухший делегат, чем упасть с эксепшеном.

                                      > Достаточно считать что деструктор в сишарпе это не то же что деструктор в C++ и проблем с терминологией не будет.

                                      Если вы работаете на проекте с плюсовиками, то чтобы их не путать, лучше называть так, как это написано в стандарте. И как это стараются переписать в MSDN (можно сравнить старые и свежие версии статей, майкрософт постепенно подчищает за собой это недоразумение).
                                        0
                                        А как же семейство инструкций ldc.*? а декларации literal?

                                        Про литералы/константы понятно. Я про то что нет никакого аналога Ldsflda чтобы достать скажем int.MaxValue.


                                        В JS всё всегда передаётся по значению (как и в Java), просто там нет двух ортогональных понятий (тип объекта и способ передачи), поэтому к такому относятся более снисходительно. Хотя у меня ни один кандидат, давший такой ответ, собеседование не прошёл.

                                        Там была задачка на завуалированное a => a = {x: 10} vs a => a.x = 10, которое очевидным образом в одном случае работало, а в другом — нет. Что-то вокруг const/let/var, уже не помню детали к сожалению. Но я помню что я жсерам рассказывал про то как в памяти ссылки устроены и почему эти два действия дают разный результат.


                                        С тем, что не стоит это использовать без крайней необходимости (напр. PInvoke), соглашусь. Но знать и понимать это надо.

                                        Ну, лишние знания не вредят, но вопрос приоритетов. 99% разрабов например кейворд unsafe не пригодился ни разу в жизни и они может вообще не знают, что это и зачем нужно. Нужно ли им его знать и понимать? В целом, скорее нет, чем да. В общем, как я уже сказал, знания лишними не бывают, но это я бы отнёс к очень низкоприоритетным.


                                        Ну так он же разрешает. Значит лучше вызвать протухший делегат, чем упасть с эксепшеном.

                                        Не факт. Жесткое падение зачастую лучше молчаливой некорректной работы. Даже если дока такое разрешает. А может и лучше, как я уже сказал, это вопрос конкретного приложения как мне кажется, и если вы решили что вам лучше вызвать отписавшийся делегат — ну, возможно в вашем случае так и нужно. Минусы выше без комментов как раз показывает, что любое сомнение в этой мантре — грех. Не надо сомневаться — надо писать вот так как в книжке написано :)

                                          0
                                          > Жесткое падение зачастую лучше молчаливой некорректной работы

                                          так и в некорректном варианте возможен молчаливый вызов уже отписанного делегата. Падение эксепшена там ни разу не гарантированно. Да, я видел этот эффект в жизни.
                                          0

                                          Старые статьи на MSDN не потёрли, а хорошенько перепрятали:


                                        0

                                        del

                                          0

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

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

                                          Самое читаемое