Когда this == null: невыдуманная история из мира CLR

    Довелось как-то раз отлаживать вот такой код на C#, который «на ровном месте» падал с NullReferenceException:

    	public class Tester {
    		public string Property { get; set; }
    		public void Foo() {
    			this.Property = "Some string"; // NullReferenceException
    		}
    	}
    

    Да, вот на этой самой строчке с присвоением свойства падал NullReferenceException. Что за дела, думаю — неужели рантайм перестал проверять наличие экземпляра перед вызовом экземплярных методов?

    Как оказалось — в некотором роде да, перестал. Правда, и компилятор оказался не тем, за кого себя выдаёт, да и проверки вовсе не гарантированы рантаймом… Подробнее — под катом.

    Для тех, кто не знаком со спецификой C#, поясню цепочку своих размышлений. Итак, в классе Tester есть экземплярный метод Foo и экземплярное же свойство Property. Некто вызвал метод Foo, но на обращении к this.Property обнаружилась неожиданность, которая привела к генерации рантаймом исключения NullReferenceException.

    В обычной ситуации это исключение могло бы означать, что в данной строке this == null, и поэтому строка this.Property = smth не может получить доступ к свойству. Но для программиста на C# это звучит совершенно невозможным образом — ведь если был как-то вызван метод Foo, то экземпляр класса существует и this не может равняться null! Как можно было вызвать метод у null?

    И тем не менее, стектрейс-то вот он, указывает на эту строку! Начинаем сомневаться во всём подряд, включая собственную вменяемость, и пишем следующую тестовую программу на C#:

    static class Program {
        static void Main() {
            Tester t = null;
            t.Foo();
        }
    }
    

    Компилируем, выполняем — да, программа падает с NullReferenceException на строке t.Foo();, но в метод Foo не заходит. Это что же получается, при каких-то условиях рантайм забыл выполнить проверку на null?

    На самом деле, нет. (Рантайм вообще не выполняет этой проверки.) Виноват во всём происходящем, конечно, не рантайм, а компилятор. Только вот не компилятор C# (который, очевидно, на своей стороне законы соблюдает и не даёт вызвать метод у null), а компилятор C++/CLI, с помощью которого был скомпилирован код, оригинальным способом вызвавший метод Foo. Да-да, участие C++/CLI в этой истории сразу бы вызвало много подозрений, и я изначально специально об этом умолчал, чтобы было поинтереснее :)

    Ну что же, продолжим опыты и напишем такую же программу на C++/CLI (для этого нужно добавить ссылку на сборку, содержащую класс Tester):

    int main() {
       Tester ^t = nullptr;
       t->Foo();
    }
    

    Компилируем, запускаем — бац! Падает NullReferenceException внутри метода Foo, как раз как в исходном случае. То есть экземплярный метод Foo каким-то образом всё-таки был вызван у нулевой ссылки в обход любых проверок.

    Что же происходит? У нас в руках две совершенно одинаковые программы на разных языках. Предполагаем, что они должны скомпилироваться в практически одинаковый (ну или хотя бы похожий) байткод, если компиляторы обоих языков соответствуют спецификациям CLI. Начинаем разбираться с полученным байткодом. Берём ildasm и разбираем код программы на C#. Привожу полный листинг метода Program.Main (в комментариях привёл строки исходного кода, соответствующие байткоду):

    .method private hidebysig static void  Main() cil managed
    {
      .entrypoint
      // Code size       11 (0xb)
      .maxstack  1
      .locals init ([0] class [Shared]ThisIsNull.Tester t)
      IL_0000:  nop
      IL_0001:  ldnull
      IL_0002:  stloc.0 // Tester t = null;
      IL_0003:  ldloc.0
      IL_0004:  callvirt   instance void [Shared]ThisIsNull.Tester::Foo() // t.Foo()
      IL_0009:  nop
      IL_000a:  ret
    }
    

    Самое интересное тут — строка IL_0004. Видим, что компилятор вызвал метод Foo с помощью инструкции callvirt. А теперь сравним с соответствующим кодом на C++/CLI:

    .method assembly static int32 modopt([mscorlib]System.Runtime.CompilerServices.CallConvCdecl) 
            main() cil managed
    {
      .vtentry 1 : 1
      // Code size       12 (0xc)
      .maxstack  1
      .locals ([0] class [Shared]ThisIsNull.Tester t)
      IL_0000:  ldnull
      IL_0001:  stloc.0 // Tester ^t = nullptr;
      IL_0002:  ldnull
      IL_0003:  stloc.0 // t = nullptr;
      IL_0004:  ldloc.0
      IL_0005:  call       instance void [Shared]ThisIsNull.Tester::Foo() // t->Foo();
      IL_000a:  ldc.i4.0
      IL_000b:  ret
    }
    

    Из интересных для нас изменений, помимо двойного зануления переменной, тут вызов метода не через callvirt, а через call.

    Инструкция CIL callvirt предназначена вообще-то для виртуальных вызовов. Однако она обладает ещё одной небольшой особенностью — поскольку виртуальные вызовы обычно делаются в CLI через таблицу виртуальных методов, то обязанностью инструкции callvirt является также проверить ссылку на null и выбросить исключение NullReferenceException, если что-то пошло не так.

    Инструкция call же просто вызывает метод, не проверяя ссылок (и не задействуя механизмов виртуальной диспетчеризации).

    Получается, что компилятор C# просто использует особенность инструкции callvirt и поэтому генерирует её для всех вызовов вообще (кроме статических и явных вызовов методов базового класса через base.) — только лишь потому, что это защищает код от вызова метода у нулевой ссылки. В то же время компилятор C++/CLI действует по старым добрым законам дикого Запада undefined behavior: если содержимое ссылки не определено, то и поведение программы тоже не определено. Если компилятор знает, что метод не может быть виртуальным, то он и не попытается генерировать виртуальных вызовов.

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

    В контексте исследованных фактов интересен также, например, вот такой фрагмент опубликованного кода класса System.String, который когда-то вызвал вопросы на StackOverflow:

            public bool Equals(String value) { 
                if (this == null)                        //this is necessary to guard against reverse-pinvokes and
                    throw new NullReferenceException();  //other callers who do not use the callvirt instruction
    
                if (value == null) 
                    return false;
     
                if (Object.ReferenceEquals(this, value)) 
                    return true;
     
                return EqualsHelper(this, value);
            }
    

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

    В нескольких методах разработчикам фреймворка пришлось защищаться от вызовов методов на null вот таким вот способом. Дело в том, что сравнение строк в методе EqualsHelper реализовано с помощью unsafe-кода, который вполне может попытаться обратиться к участку памяти по нулевому адресу, что наверняка приведёт ко всякого рода нехорошим последствиям.
    UPD: Пользователь a553 верно замечает в комментариях, что таким кодом разработчики, помимо прочего, исправили потенциальную ошибку, при которой вызов ((string)null).Equals(null) мог вернуть false, а не упасть с NullReferenceException, как ему положено.

    Выводы:


    1. CLI не гарантирует, что this != null даже при вызове экземплярных методов и свойств.
    2. Компилятор C# соблюдает это правило при генерации байткода для кода на C#, но ваш код может быть вызван и из других языков.
    3. В частности, компилятор C++/CLI этих правил не соблюдает и вполне может передавать управление в экземплярные методы, не определяя соответствующего экземпляра.
    4. Отсюда следует, что ваш код иногда может быть вызван в контексте this == null по различным причинам (кодогенерация, reflection, компиляторы других языков), и к этому нужно быть готовым. Если вы разрабатываете библиотеку, предназначенную для широкого использования в interop-среде, возможно, стоит даже добавить проверки на null в публичные методы доступных извне классов.

    PS:


    Весь код, использованный в статье, доступен на github.
    Метки:
    Enterra 40,54
    Компания
    Поделиться публикацией
    Комментарии 67
    • +2
      Из-за этой особенности C# (использование callvirt где ни попадя) вещи типа string.IsNullOrEmpty пришлось выносить в статические методы. Методы-расширения спасают, но не сильно, т. к. нет доступа к приватным полям.
      • 0
        Хм, интересно, а friend-нотация из C++ как-нибудь транслируется в CLI? Если да, то она бы могла помочь получить доступ к приватным полям, и дело «за малым» — получить соответствующий синтаксис в C# :)

        Я только припоминаю, что в VB.NET есть какие-то Friend Class'ы, но, кажется, это что-то типа нашего internal.
        • 0
          Нет, никак не транслируются, friend это фича исключительно на уровне нативного исходного кода C++. Так что от статических методов нам пока никуда не уйти.
        • 0
          Мне кажется, возникновение статических методов не связано с вашим утверждением. Как это сделать иначе, если не так?
          • +3
            Вы немного не поняли. Проверка из серии myString.IsNullOrEmpty в коде намного лучше смотрится именно в такой форме — как вызов метода объекта. Собственно, у многих в проектах есть набор методов-расширений к типу string, которые стучатся к статическим же методам типа string. Такое костылестроение случилось из-за того что компилятор C# везде использует инструкцию callvirt, не позволяющую вынести проверку (this == null || this.Length == 0) в настоящий instance-метод.
            • +4
              С другой стороны, если бы компилятор не проверял this на null при вызове, то пришлось бы городить ещё большие костыли в каждом из методов, либо довольствоваться менее понятными исключениями, когда NullReferenceException выбрасывался бы изнутри вызываемого метода, а не в месте его вызова. Так что, имхо, компилятор всё делает правильно.
              • +1
                В общем случае поведение при callvirt, как способе вызова по умолчанию, соответствует ожиданиям. Другое дело, что иногда (например, string.IsNullOrEmpty) было бы проще реализовать не через дефолтное поведение. Как вариант — специфичный аттрибут. Но, видимо, это настолько редкий кейс, что в свое время его не продумали, а потом реализовывать в последующих версиях было дорого в плане архитектуры.
              • +1
                Проверка из серии myString.IsNullOrEmpty в коде намного лучше смотрится именно в такой форме

                Вопрос неоднозначный: при наличии такой возможности, получается IsNullOrWhiteSpace тоже так нужно было бы реализовывать (для симметрии/полноты)?
                Тогда уж у каждого объекта еще метод IsNull должен быть.
                А с другой стороны — может, и красивая идея?
                • 0
                  У нас вот такой есть набор методов
                  Скрытый текст
                  		public static TResult IfNotNull<TValue, TResult> (this TValue arg, Func<TValue, TResult> transform) where TValue : class where TResult : class
                  		{
                  			if (arg == null)
                  				return null;
                  			return transform (arg);
                  		}
                  
                  		public static TResult? IfNotNull<TValue, TResult> (this TValue arg, Func<TValue, TResult?> transform)
                  			where TValue : class
                  			where TResult : struct
                  		{
                  			if (arg == null)
                  				return null;
                  			return transform (arg);
                  		}
                  
                  		public static TResult IfHasValue<TValue, TResult> (this TValue? arg, Func<TValue, TResult> transform)
                  			where TValue : struct
                  			where TResult : class 
                  		{
                  			if (arg == null)
                  				return null;
                  			return transform (arg.Value);
                  		}
                  
                  		public static TResult? IfHasValue<TValue, TResult> (this TValue? arg, Func<TValue, TResult?> transform)
                  			where TValue : struct 
                  			where TResult : struct
                  		{
                  			if (arg == null)
                  				return null;
                  			return transform (arg.Value);
                  		}
                  

        • +4
          Как-то у вас внезапно статья закончилась. Я надеялся увидеть способ вызвать виртуальный метод с this == null (это есть на Stack Overflow) и особенности call/callvirt и генериков.

          … реализовано с помощью unsafe-кода, который вполне может попытаться обратиться к участку памяти по нулевому адресу, что наверняка приведёт ко всякого рода нехорошим последствиям.
          Проверка туда поставлена для того, чтобы null.Equals(null) не возвращал false, как было в .NET 2.0, а кидал исключение. Небезопасный код всё равно кинет NullReferenceException.

          CLI не гарантирует, что this != null даже при вызове экземплярных методов и свойств.
          Спек CLI явно разрешает this == null, следующие пункты смысла особо не имеют ввиду этого.

          Отсюда следует, что ваш код иногда может быть вызван в контексте this == null по различным причинам (кодогенерация, reflection, компиляторы других языков), и к этому нужно быть готовым. Если вы разрабатываете библиотеку, предназначенную для широкого использования в interop-среде, возможно, стоит даже добавить проверки на null в публичные методы доступных извне классов.
          Вредный совет. Во-первых, код всё равно упадёт на первой попытке доступа к такому объекту. Во-вторых, это не спасёт от 1 вместо 0 в this.

          Полезнее было бы проверять типы переменных (f(string a) { if (!(a is string)) throw new ArgumentException(...); ... }), поскольку вызовы обычных методов на них попытаются завершиться молча, а вызовы виртуальных методов положат весь рантайм.

          Но мне кажется лучше верить в добросовестность клиентского кода, и вдаваться в такую паранойю только уж в совсем экстренных случаях, централизованно и безопасно.
          • 0
            особенности call/callvirt и генериков.
            А я не в курсе. Может, расскажете? Интересно же!

            Проверка туда поставлена для того, чтобы null.Equals(null) не возвращал false, как было в .NET 2.0, а кидал исключение.
            Да, тут вы правы, я сейчас исправлю этот фрагмент в статье.

            Небезопасный код всё равно кинет NullReferenceException.
            С небезопасным кодом не всегда можно быть уверенным во всём. Вот есть, например, такой пример из книжки «Expert .NET 2.0 IL Assembler» (это вызов sscanf с null в качестве форматной строки). Автор утверждает, что у него этот код падал с NullReferenceException, однако в современном рантайме он кидает другое исключение — AccessViolationException. Я, конечно, понимаю, что интероп и unsafe — это разные вещи, но лучше, на мой взгляд, таким не баловаться и нуллы не передавать ни туда, ни туда.

            Во-первых, код всё равно упадёт на первой попытке доступа к такому объекту.
            Да, конечно, упадёт. Но стоит учитывать, что в этом случае конечный пользователь может увидеть невнятный стектрейс. Я считаю, что если вы пишете библиотеку, которую будут использовать сторонние пользователи — лучше по возможности проводить валидацию аргументов на входе в библиотеку, и выбрасывать такого рода исключения сразу при вызове публичного API, а не откуда-то изнутри своего кода.

            Во-вторых, это не спасёт от 1 вместо 0 в this.
            А есть ли какой-то валидный и не слишком сложный способ передать единицу в качестве managed-ссылки?

            По поводу проверки типов f(string a) { if (!(a is string)) — я верю, что каким-то волшебным макаром можно вызвать такую функцию, подсунув ей не строку. Но как именно? Вроде как sidristij что-то такое делает, но в нормальном коде такое встречается? Я же тут вам не сказки рассказываю, а описываю самый настоящий баг, с которым я реально столкнулся. В любом случае, код, который вытворяет такое, явно делает это злонамеренно, и бороться с ним не особо-то продуктивно, как вы верно подметили.
            • +1
              я верю, что каким-то волшебным макаром можно вызвать такую функцию, подсунув ей не строку. Но как именно?

              Тут нужно понимать, что вся безопасность типов в .NET работает на высоком уровне. Т. е. она сама по себе не должна ломаться, если кто-то не начнёт её специально ломать. Если у нас есть full trust, то мы можем в память писать вообще что угодно, никакой безопасности при этом никто не гарантирует. Если при этом плохо знать структуру .NET-овских объектов, то рантайм начнёт тошнить во все стороны, unspecified behavior будет в каждой строчке. В нормальном коде такое встречается крайне редко.
              • 0
                А я не в курсе. Может, расскажете? Интересно же!
                Упадёт, потому что генерик методы диспетчеризуются динамически в рантайме. Но, насколько я знаю, может не упасть в некоторых случаях, если существует скомпилированный генерик метод для референс типов.

                Пример выдаёт AccessViolationException потому что sscanf реализован в неуправляемом фрейме, ошибки сегментации там всегда преобразуются в AV. В управляемом фрейме код исключения EPOINTER конвертируется в NullReferenceException, если адрес < 65536 (<= в .NET 2.0), иначе AV.

                А есть ли какой-то валидный и не слишком сложный способ передать единицу в качестве managed-ссылки?
                C++/CLI, трюк с FieldOffset или банально ldc.i.1; conv.i; stloc.0. В нормальном коде я с таким встретился, когда изменился API, а малоуправляемый код не был перекомпилирован.
            • –2
              Если все верно помню, в конструкторе абстрактного класса this == null это норма.
              • +18
                В принципе не вижу ничего удивительного, идеология С++ — максимальная производительность и принцип «вы не платите за то, что не используете», принцип С# — максимальная безопасность и принцип «лучше перебдеть, чем недобдеть». Оба компилятора ведут себя в пределах своей базовой идеологии.
                • +2
                  Чеканно! Подписываюсь. Поэтому и выбираю C#. называйте меня лентяем :))))
                  • +3
                    Это не лень, а естественное желание сосредоточиться на архитектуре, алгоритмах и бизнес-логике вместо того, чтобы тратить кучу времени на работу с низкоуровневыми проблемами.
                    • +1
                      Правильный выбор архитектуры практически исключает затраты времени на работу с низкоуровневыми проблемами :)
                      Низкоуровневые проблемы плюсов вылазят лишь с изначально плохим кодом или у совсем новичков.
                      • +2
                        Почитайте посты Andrey2008 про статический анализ крупных плюсовых проектов. То ли везде изначально плохой код, то ли везде совсем новички, то ли C# действительно просто не позволяет делать многих ошибок.
                        • 0
                          Спасибо за ссылку. Посты автора про разыменование нулевого указателя видел и ранее. Эти посты и посты на тему «PVS-Studio vs Cppcheck» относятся к той теме, о которой вы говорите?

                          Про C#: а досадно, что C#, тем не менее, позволяет совершать некоторые ошибки, для недопущения которых не требуется новая парадигма/платформа, достаточно было сделать C#, CLR, стандартную библиотеку классов чуть продуманнее и строже.
                          • +1
                            Моя основная мысль: программисту сложно сделать ошибку на C# в среднестатистическом коде по неосмотрительности или забывчивости. Половину стандартных ошибок запрещает делать компилятор — программа просто не будет скомпилирована. А если компиляция пройдёт успешно, то другая половина ошибок будет проверена в рантайме средой исполнения. Например, C#-программу практически нереально атаковать через переполнение буфера: если вы забыли проверить размерность массива, то .NET сделает это за вас. Да, приходится немножко платить за это общей производительностью, но это маленькая цена за то, что уходит намного меньше времени на продумывание низкоуровневых нюансов.
                            По поводу строгости. Если вы хорошо знаете платформу, то у вас есть возможность написать крутой и быстрый код. Тот же unsafe позволит вам конкурировать по скорости с плюсовыми приложениями. Но при этом нужно чертовски хорошо понимать происходящее. В CLR специально сделали ряд уступок по скорости, чтобы при желании и достаточном уровне знаний можно было писать очень клёвые штуки.
                            По поводу PVS-Studio. Мне доводилось встречаться с Андреем (автором постов) лично. И я спросил у него: а нет ли планов сделать подобный анализатор для C#. Он мне ответил, что это абсолютно бессмысленно, т. к. C# компилятор уже делает большую часть проверок. От себя добавлю, что скоро у нас появится Roslyn, который позволит писать крутые высокооуровневые проверки кода, которые сделают количество ошибок ещё меньше. Для примера посмотрите недавний пост Сергея Теплякова: Анализатор исключений на базе Roslyn-а.
                            • 0
                              Например, C#-программу практически нереально атаковать через переполнение буфера: если вы забыли проверить размерность массива, то .NET сделает это за вас.


                              Что Вам мешает в плюсах использовать безопасные контейнеры которые проведут точно те же проверки?

                              Плюсы позволяют делать все то же самое что си шарп, плюс многое другое.
                              Вопрос «плюсы не проведут те же проверки что си-шарп» упирается сугубо в желание программиста разрешить плюсам эти проверки проводить.

                              Подавляющее большинство реальных проблем с переполнением буфера, как несложно убедиться, относятся к C-коду. И да, чистый Си — это уже совсем другой уровень где чертовски много нужно делать руками.
                              • +1
                                Что Вам мешает в плюсах использовать безопасные контейнеры которые проведут точно те же проверки?

                                А потом мне надо unique_ptr перебросить как-то в соседний поток и начинается веселье.
                                • 0
                                  А потом мне надо unique_ptr перебросить как-то в соседний поток и начинается веселье.


                                  *Задумчиво смотрит на множество unique_ptr в коде 3D-сканера где 20+ потоков успешно работают над обработкой данных в реальном масштабе времени*

                                  А если не секрет
                                  а) зачем?
                                  и
                                  б) в чем веселье?
                                • 0
                                  Плюсы позволяют делать все то же самое что си шарп, плюс многое другое.

                                  А ASM позволит мне делать ещё больше.
                                  Тут скорее вопрос в дефолтном подходе и общей идеологии. C# сделан так, что случайно выстрелить себе в ногу было намного сложнее.
                                  • 0
                                    А ASM позволит мне делать ещё больше.


                                    Нет. ASM даже близко не позволит делать то же самое в коде.
                                    Более того, сегодня он и по скорости проиграет плюсовому коду.

                                    Тут скорее вопрос в дефолтном подходе и общей идеологии. C# сделан так, что случайно выстрелить себе в ногу было намного сложнее.


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

                                    Пункт б) делает разработку удобнее, пункт а) позволяет допускать на проект менее опытных программистов без риска того что они наломают дров. В теории все супер, в реальной практике оба преимущества срабатывают лишь наполовину — возможность наломать дров хотя и сокращается, но все равно остается огромной, а стандартные библиотеки все равно приходится чем-то дополнять.
                                    • +2
                                      Нет. ASM даже близко не позволит делать то же самое в коде.
                                      Более того, сегодня он и по скорости проиграет плюсовому коду.

                                      Да ладно! Согласен, кода получится в 600 раз больше, человекочасов уйдёт в 600 раз больше, придётся написать код под каждую железку, но в итоге можно получить более производительное решение. Взять, к примеру KolibriOS. Дистрибутив влезает на дискету, для запуска нужно 8 MB оперативы. Имеются драйверы, браузер, графический редактор, игры и ещё куча всякой всячины. Как считаете, отчего авторы взяли для написания ядра FASM, а не C++? Сможете ли вы написать аналог KolibriOS на плюсах, чтобы он работал быстрее и жрал меньше памяти?

                                      В теории все супер, в реальной практике оба преимущества срабатывают лишь наполовину

                                      Ок, если плюсы так хороши, то отчего же управляемые языки (C# и Java) отгрызли себе такой кусок рынка? Почему бы всем и всё не писать на плюсах? Моё мнение такое: управляемый подход сокращает время на разработку, снижает её стоимость, не даёт делать многих тупых багов. А вы как думаете?
                                      • 0
                                        Да ладно! Согласен, кода получится в 600 раз больше, человекочасов уйдёт в 600 раз больше, придётся написать код под каждую железку, но в итоге можно получить более производительное решение.


                                        Можно. А можно запросто и проиграть хорошему оптимизирующему компилятору. Нет, путем перебора со временем можно найти asm который будет быстрее на данной конкретной машине, но это будет не слишком быстрый путь и для сколь-либо большого объема кода он запредельно трудоемок. Сейчас компилятор только что векторизацию делает не очень хорошо, да и то — смотря какой компилятор.

                                        Как считаете, отчего авторы взяли для написания ядра FASM, а не C++?


                                        Очевидно же что из любви к искусству

                                        Сможете ли вы написать аналог KolibriOS на плюсах, чтобы он работал быстрее и жрал меньше памяти?


                                        Я позволю себе робко напомнить, что надлежащим образом ужатый Linux тоже может влезть на дискетку и в 8 мб памяти — при том, что изначально там такой задачи вообще не ставилось. Можно ли создать аналог KolibriOS на плюсах так чтобы он обладал примерно теми же +-10% характеристиками по скорости, размеру и памяти? На мой взгляд — безусловно можно. Но только зачем?

                                        Почему бы всем и всё не писать на плюсах?


                                        Потому что серебряных пуль не бывает :D?
                                        Специализированные решения всегда выигрывают в тех задачах под которые они затачиваются.
                                        Для одних задач оптимальны плюсы, для других — шарп, для третьих — питон, для четвертых — прости господи, PHP.

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


                                        Мое мнение такое: для многих типовых задач небольшого и среднего размера C# / Java сокращает время на разработку, снижает её стоимость, сокращает число тупых багов. Для
                                        а) крупных проектов
                                        б) нетривиальных задач
                                        в) задач где важна производительность
                                        плюсы, особенно C++11, не проигрывают шарпу ни в чем, но имеют ряд преимуществ.
                                        • 0
                                          Для одних задач оптимальны плюсы, для других — шарп, для третьих — питон, для четвертых — прости господи, PHP.

                                          Отлично, мы приближаемся к консенсусу. С этим абсолютно согласен. Даже про проекты на пых-пыхе.

                                          Для
                                          а) крупных проектов
                                          б) нетривиальных задач
                                          в) задач где важна производительность
                                          плюсы, особенно C++11, не проигрывают шарпу ни в чем, но имеют ряд преимуществ.

                                          А вот тут не соглашусь.
                                          а) Зависит от специфики крупного проекта. Мне доводилось принимать участие в ряде весьма крупных проектов на C#. Работать было легко и приятно, я думал главным образом про дизайн и алгоритмы. Возводить подобное на плюсах я бы в жизни не согласился.
                                          б) Что вы имеете в виду?
                                          в) Тут опять важная специфика, от неё многое зависит. Современный CLR позволяет написать критичные по производительности куски кода так, что по скорости они вполне могут соревноваться с плюсовыми аналогами. Например, я сейчас работаю в проекте по обработке изображений. На низком уровне мы используем базовые методы OpenCV, а основной код (включая большую и хитрую логику по применению различных преобразований и их обработке) целиком написан на C#. Все наши алгоритмы просто летают, в то время как .NET позволяет сделать остальную разработку сплошным удовольствием. В другом проекте я также отвечал за сложные алгоритмы и тяжёлую математику. Пришлось немного постараться, в некоторых местах отказаться от прелестей ООП, но в итоге алгоритмы работают крайне быстро. А знакомые у меня занимаются 3D-рендерингом: 100% C# код написан очень хорошо, а работает настолько быстро, что плюсовые конкуренты нервно курят в сторонке.
                                          Небольшой итог: производительный код на C# вполне писать можно, лишь бы руки из того места росли. Согласен, что есть проекты на которых без С++ никуда. Но они вовсе не обязательно нужны для крупного перфоменс-критикал проекта.

                                          Предлагаю закончить нашу небольшую дискуссию и остановиться на следующей мысли: для каждой задачи хорош свой язык. Есть проекты, которые лучше делать на шарпе, а есть — которые лучше делать на плюсах. При выборе нужно исходить из конкретного чёткого ТЗ. Согласны?
                                          • +1
                                            Я предлагаю все же закрыть вопрос с исходным тезисом

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


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

                                            Возводить подобное на плюсах я бы в жизни не согласился.


                                            Глаза боятся, а руки делают :). Я сам имел достаточно неприятный опыт работы с большими legacy-проектами на плюсах и да, там бывает море проблем. Но мы два года назад стали делать очень сложный проект на плюсах более-менее с нуля, сразу грамотно заложили архитектуру — и конечный результат реально получился на заглядение. Есть свои косяки, но очень четко видно, что они вылазят там где мы схалтурили с архитектурой. При этом проблем типа нулевых указателей где могли бы проявиться преимущества C# в проекте «волшебным» образом практически не оказалось — мы бы получили те же косяки в таком же коде на шарпе.

                                            При этом фронт-энд к этому проекту на 80% написанному на плюсах народ в другом офисе замутил несмотря на наше сопротивление на шарпе. И ничего хорошего из этого не выросло, глядя на то неудобоваримое нечто которое ухитряется при концептуальной простоте интерфейса каким-то образом падать (причем так что тяжело разобрать даже почему код банально не собирается после мерджа или странным образом крэшится на этапе загрузки) я на 100% уверен что если бы проект сделали на Qt, то он был бы на порядок изящнее и надежнее в UI.

                                            а) Зависит от специфики крупного проекта.


                                            Согласен. Но в любом случае чем крупнее проект — тем бледнее преимущества C#.
                                            Плюсы имеют некий более-менее фиксированный оверхед по сравнению с шарпом. На мелких проектах это ощутимо, на крупных — размер оверхеда слишком мал по сравнению с размером проекта.

                                            б) Что вы имеете в виду?


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

                                            На низком уровне мы используем базовые методы OpenCV, а основной код (включая большую и хитрую логику по применению различных преобразований и их обработке) целиком написан на C#


                                            Давайте я угадаю: именно на этот низкий плюсовый уровень приходится 90% процессорного времени, да :)?
                                            Ну таки да, если проект сводится к UI-обертке над плюсовыми библиотеками, то он будет быстрым :)

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


                                            Да конечно можно, кто спорит? Но плюсовый код будет чуточку быстрее (что обычно не существенно, но иногда важно) и написать его будет проще (что всегда весьма существенно).
                                            • 0
                                              Ладно, у нас с вами разные вероисповедания, к консенсусу прийти не получится.

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

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

                                              сразу грамотно заложили архитектуру — и конечный результат реально получился на заглядение

                                              Молодцы! Тут не спорю, написать нормально можно, если навыки имеются.

                                              При этом фронт-энд к этому проекту на 80% написанному на плюсах народ в другом офисе замутил несмотря на наше сопротивление на шарпе.

                                              Мешать вредно для здоровья, тут тоже не спорю. А если с программированием большие проблемы, то C#-магия не спасёт от написания плохого кода.

                                              Но в любом случае чем крупнее проект — тем бледнее преимущества C#.

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

                                              Там где в шарпе нет готовых решений которые можно сразу зареюзать его полезность резко падает.

                                              Надеюсь, вы не будете спорить, что есть шарповские решения без аналогов на плюсах? Полезность использования конкретных библиотек под конкретные задачи нужно оценивать из ТЗ.

                                              Давайте я угадаю: именно на этот низкий плюсовый уровень приходится 90% процессорного времени, да :)?

                                              Не угадали. Грубая оценка — 50% приходится на наши C#-алгоритмы. И я что-то сильно сомневаюсь, что напиши мы всю на плюсах, расклад резко поменялся бы.

                                              и написать его будет проще

                                              Нет, не договоримся мы. Давайте теперь я угадаю: вам не приходилось проектировать и разрабатывать C#-приложения (хорошие приложения, чтобы была грамотная архитектура, с которой удобно работать), размер которых исчислялся в сотнях тысяч строк кода. И маленькое признание: мне не доводилось иметь дело с проектами подобных размеров на плюсах. Очевидно, что вам проще писать на C++, а мне — на C#. При таком раскладе оба наших мнения предвзяты. Невозможно прийти к соглашению бросаясь друг в друга отдельными кусками личного опыта. Увы, подробный анализ ситуации займёт крайне много времени.
                                              • 0
                                                Ну, я просто полагаю что архитектура первична, язык — вторичен.
                                                С нормальной архитектурой Вы не столкнетесь с «проблемами С++», с плохой — получите эпическую кучу проблем с шарпом.
                                                Плюсы дают значительно большую гибкость в проектировании архитектуры. На мой взгляд это большой плюс, поскольку я могу сделать решение лучше подходящее к выбранному проекту. На Ваш взгляд это большой минус, потому что у индуса зачем-то посаженного на проектирование архитектуры есть море возможностей соорудить монстра.
                                                Мне показался интересным тезис что у шарповых приложений легче исправлять архитектурные ошибки. Если это так, что это действительно будет большим преимуществом шарпа, но я не очень себе представляю за счет чего это именно в шарпе возможно.

                                                Не угадали. Грубая оценка — 50% приходится на наши C#-алгоритмы. И я что-то сильно сомневаюсь, что напиши мы всю на плюсах, расклад резко поменялся бы.


                                                А при указанной Вами цифре «50%» идеальный код в C#-части в принципе может увеличить производительность приложения лишь вдвое :). Верно и обратное — даже если Вы прикрутите к эффективному коду неэффективную часть, то неэффективность последней станет заметна не сразу. Но тут действительно ничего нельзя сказать не зная деталей, так что верю Вам на слово.
                                              • +1
                                                Простите, что врываюсь в ваш спор.
                                                … чем крупнее проект — тем бледнее преимущества C#.
                                                … плюсовый код будет чуточку быстрее (что обычно не существенно, но иногда важно) и написать его будет проще (что всегда весьма существенно).
                                                Я энтерпрайз разработчик на С++ и C#, и вы тут ну совсем не правы.

                                                DreamWalker меня опередил :)
                                                • +1
                                                  Спасибо за поддержку! Могу ещё добавить, что у меня есть пара десятков знакомых, которые перешли из C++ мира в мир C# и не разу об этом не пожалели. Хоть у меня и нет собственного особого плюс-плюс-опыта, но их авторитетному мнению я доверяю.
                                                  • 0
                                                    А если не секрет — в чем преимущества шарпа в крупных проектах?
                                                    Я специализируюсь на CAD и CG, про энтерпрайз если речь о проектах построенных вокруг БД мало чего могу сказать.
                                                    Из полезного которого я видел — на шарпе несколько быстрее проекты компилятором собираются. Есть ли что-то еще?
                                                    • +1
                                                      Из фич языка: GC и арена, функции высшего порядка типа Linq, делегаты, Expressions, генерируемый код, отражение, исключения, объектная модель, асинхронная модель, сериализация, модель ресурсов, overflow проверки.

                                                      Из workflow: статический анализ, АОП и жизнь после компилятора вообще, расширяемость компилятора, скорость сборки, тестирование, nuget (актуально для Windows).

                                                      Разумеется, многое из этого есть в С++, особенно 11-14, но не настолько развито и нативно встроенно в среду. Если бы я сравнивал большие проекты на С++ и C# с точки зрения эффективности разработки, я бы привёл в пример такие проекты, как CMake, Source (Valve), Unreal от С++ и WPF, Autofac и NHibernate от C#.

                                                      Но если у вас большое количество нецелых вычислений, я бы всерьез рассматривал С++ при начале проекта. C# в этой области достаточно слаб.
                                                      • 0
                                                        Ну, у меня 2/3 кода связано с нецелыми вычислениями, но это не значит что в оставшейся трети мало интересных и важных задач :-).

                                                        У практически всех фич которые Вы назвали есть аналоги в плюсах. Согласен что в плюсах некоторые вещи надо брать из библиотек и/или встраивать в архитектуру и тратить на это время тогда как в плюсах та же функциональность доступна «из коробки». Но это и есть тот самый «относительно фиксированный оверхед» о котором я говорил. Один раз делается нужная фича и всё.
                                                        • 0
                                                          Не сказал бы, что фиксированный. Скорее экспоненциальный. Чем больше кода, тем больше проблем у вас будет от недостатка всего перечисленного.

                                                          Ну да ладно. Не хочу снова спор начинать.
                                                          • 0
                                                            Так не будет «недостатка». Будут те же фичи, просто не как часть языка, а как часть архитектуры реализованной на этом языке.
                                            • +1
                                              если плюсы так хороши, то отчего же управляемые языки (C# и Java) отгрызли себе такой кусок рынка? Почему бы всем и всё не писать на плюсах?

                                              А если бы C++ разрабатывался с нуля. Например, если бы в нем не было чистого C, были только «умные» указатели, да и к тем же целым числам нельзя было применять if («int i = 0; if (i =1) { }»), а только к нативному bool, и десятки подобных моментов?

                                              Возможно, дело не только в управляемости/неуправляемости, а и в том, что эти языки имеют несопоставимые объемы груза обратной совместимости?
                                              • 0
                                                Исторический аспект важен, согласен. Но мы обсуждаем текущую сложившуюся ситуацию. Я утверждаю, что на сегодняшний день для многих проектов (но не для всех) лучше использовать C#, а не С++. Я не говорил, что С++ нельзя переписать с нуля так, чтобы общая философия сохранилась, а новый язык получился лучше шарпа. Но такого языка нет. А C# сам по себе и есть попытка переписать C с нуля, чтобы программировать было проще. Всякие штуки типа управляемости — аспекты нового подхода.
                                                • 0
                                                  А если бы C++ разрабатывался с нуля. Например, если бы в нем не было чистого C, были только «умные» указатели, да и к тем же целым числам нельзя было применять if («int i = 0; if (i =1) { }»), а только к нативному bool, и десятки подобных моментов?
                                                  Мне кажется, вы сейчас описываете некий такой аналог Rust, D или Go :)
                                    • 0
                                      Ну, для начала там реально в половине проектов плохой код (там банально C, а не C++ — почувствуйте разницу)
                                      А в оставшихся присмотритесь внимательнее — анализатор ловит ошибки в очень редко используемых частях кода.
                                      При этом половина ошибок которые он ловит точно так же возможна в C#.
                                    • 0
                                      Архитектура != код.
                              • +1
                                public static ReadOnlyCollection<Char> WordsDelimeters = new ReadOnlyCollection<Char>(
                                            new List<Char> { '\'', ',', '-', '/', '.', '\\', ' ' });
                                

                                Недавно код крашился с NullReferrenceException при обращении к WordsDelimiters. (public поле это плохо, но все же :))
                                Ответ почему писать не буду, но подсказку дам — тут замешен static.
                                • +1
                                  Возможно, виноват был порядок инициализации статических полей или даже порядок вызова статических инициализаторов разных классов? Сам по себе этот участок не выглядит ошибочным, по-моему.

                                  И ещё — к делу не относится, но мне кажется, что тут хорошо бы спецификатор readonly добавить.
                                  • +1
                                    Возможно, виноват был порядок инициализации статических полей

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

                                    Еще непонятно, почему
                                    Недавно код крашился
                                    Недавно на проекте или недавно в версии framework-а.
                                    • 0
                                      Мало того, статический конструктор и статический инициализатор — это разные штуки, и порядок их вызова тоже как-то хитро определяется (пардон, вы про это и написали, сходу неверно проинтерпретировал ваш комментарий). В старых версиях рантайма добавление статического конструктора меняло логику всей инициализации, вот Скит про это пишет подробно.
                                      • 0
                                        Недавно на проекте или недавно в версии framework-а.

                                        На проекте, на разных версиях фреймворка я не смотрел
                                  • 0
                                    дубль.
                                    • +2
                                      известная проблема:
                                      sergeyteplyakov.blogspot.ru/2011/08/blog-post.html
                                      habrahabr.ru/post/131811/

                                      нужно добавить статический конструктор в класс для решения проблемы NullReferrenceException при обращении к статическому полю.
                                      • 0
                                        я бы не называл это проблемой
                                        • 0
                                          Можете пояснить подробнее?

                                          На мой взгляд, это проблема, т.к. по моим наблюдениям, к сожалению(!) программисты предпочитают инициализировать поля (в данном случае статические) по месту объявления, и статический конструктор, как правило отсутствует, если только не требуется в нем написать некий «сложный» код.

                                          В итоге есть реальные примеры, когда поле не инициализируется (остается null).

                                          И в лучшем случае будет падение NullReferenceException, а в худшем (если падение происходит при обращении к элементам этого поля из статического конструктора другого класса) будет неинформативное падение TypeItitializationExxception, а еще более худший случай — когда это падение происходит при вызове .NET-сборки из неуправляемого кода, в этом случае InnerException (т.е. NullReferenceException) / StackTrace теряются, и выдается неинформативное сообщение TypeItitializationExxception.

                                          На мой взгляд, это именно проблема.
                                          Понятно, что это происходит не из-за самой лучшей схемы работы со статиками, но это уже другой вопрос.
                                          • 0
                                            class MySingleton
                                            {
                                                 private static _instance = new Lazy<MySingleton>(()=>new MySingleton(), true);
                                                 public static MySingleton Instance {get{return _instance.Value;}}
                                            }
                                            

                                            Насколько я знаю, данная схема отвечает требованиям потокобезопасности и однократности и ленивости инициалиции и при этом не создаёт ненужных проблем с блокировками в статическом конструкторе.
                                            • 0
                                              Возможно. И хорошо, если так (хотя — экземпляр собственно Lazy-контейнера точно будет всегда создан, точно не требуется наличие статического конструктора?).
                                              В любом случае, есть проблема, связанная с известной путаницей между понятиями «можно» и «должно».

                                              1. Да, можно реализовать синглтон по вашей схеме. И на данный момент мне больше всего нравится этот вариант, через Lazy.
                                              2. Можно через обычное статическое поле, задав явно статический конструктор (при этом поле можно иницилизировать по желанию, «на месте», либо в конструкторе).
                                              3. Можно через статическое поле, которое инициализируется в свойстве с помощью блокировки и двойной проверки.

                                              4. Однако, ничто не защитит от разработчика, который добавляет статическое поле, инициализирует его «на месте», а про статический конструктор не считает должным думать.

                                              На мой взгляд, архитектура языка/платформы должна быть такова, чтобы можно было написать корректный код и только корректный (т.е., «должно»), а не просто можно было из тысячи вариантов выбрать N корректных.
                                              На примере: п. 4 — либо он должен работать, либо компилятор не должен пропускать такой код (как это сделать, другой вопрос — вплоть до отключения в языке возможности инициализировать поле «на месте»).
                                              • 0
                                                экземпляр собственно Lazy-контейнера точно будет всегда создан, точно не требуется наличие статического конструктора
                                                Можно обернуть в nested static class это поле (в котором кроме него не будет вообще ничего), тогда вот точно-точно будет создан.
                                                • 0
                                                  Так?
                                                  class MySingleton
                                                  {
                                                      private static class Nested
                                                      {
                                                          public static Lazy<MySingleton> _instance = new Lazy<MySingleton>(() => new MySingleton(), true);
                                                      }
                                                      public static MySingleton Instance { get { return Nested._instance.Value; } }
                                                  }
                                                  
                                            • 0
                                              На мой взгляд, это проблема, т.к. по моим наблюдениям, к сожалению(!) программисты предпочитают инициализировать поля (в данном случае статические) по месту объявления, и статический конструктор, как правило отсутствует, если только не требуется в нем написать некий «сложный» код.

                                              Майкрософт на самом деле рекомендует инициализировать static поля inline. У FxCop даже варнинг такой есть. Рекомендуется это из за перформансе причин (подробности по ссылке)
                                              • 0
                                                Спасибо за аргументированный ответ в пользу подхода inline-инициализации.
                                                Однако, как же тогда решать проблему, когда в случае inline-инициализации поля не инициализируются в определенных случаях при работе в многопотоке?
                                                И насколько это согласуется с принципом Dependency inversion principle (… Модули верхнего уровня не зависят от модулей нижнего уровня ...)? Если мы написали класс, который нужно использовать только определенным образом (не следующим явно из его декларации), то почему вызывающая сторона (верхний уровень) должна зависеть от деталей реализации нашего класса?

                                                И правильно ли я понимаю, что если при наличии inline-инициализации добавить пустой статик-конструктор, то мы получим мифическую потерю производительности (To fix a violation of this rule, initialize all static data when it is declared and remove the static constructor).

                                                Кстати, что это за потеря такая? Ведь тип инициализируется однократно типа через статик-конструктор.

                                                Если дело в том, что таких полей много, и инициализация каждого занимает много времени, а мы хотим Lazy-инициализацию с помощью inline-инициализации, то появляются вопросы:
                                                — насколько множество полей согласуются с первым принципом SOLID;
                                                — почему поля инициализируются долго? если это что-то типа подключения к БД, то это нужно делать точно не через статик-инициализацию (inline или конструктор, не важно); поле можно сделать статическим, но инициализировать тогда как синглтон — через Lazy или блокировку с двойной проверкой (но статический конструктор добавить придется — для гарантированной инициализации контейнера Lazy или lock-объекта).
                                                • 0
                                                  When a type declares an explicit static constructor, the just-in-time (JIT) compiler adds a check to each static method and instance constructor of the type to make sure that the static constructor was previously called.

                                                  Вот эта потеря производительности.

                                                  Про DI принцип не понял, при чем он к inline инициализации?
                                          • 0
                                            Ага, точно, я выше в комментариях пытался припомнить именно этот аспект, спасибо за ссылки :)
                                        • +2
                                          Но для программиста на C# это звучит совершенно невозможным образом

                                          Только для того, кто не знает что такое непривязанный делегат (open instance delegate).

                                          public sealed class Test
                                          {
                                              public void CheckThis()
                                              {
                                                  if (null == this)
                                                      throw new InvalidOperationException();
                                              }
                                          
                                              public static void Run()
                                              {
                                                  var openDelegate = (System.Action<Test>)Delegate.CreateDelegate(
                                                      typeof(System.Action<Test>), typeof(Test).GetMethod("CheckThis"));
                                                  openDelegate.Invoke(null);
                                              }
                                          }
                                          • +1
                                            typeof(Test).GetMethod(«CheckThis»)

                                            А не относится ли это как раз к случаю?:
                                            Отсюда следует, что ваш код иногда может быть вызван в контексте this == null по различным причинам (… reflection ...)


                                            Получается, наш открытый делегат имеет ссылку на некий метод, но экземпляр то не создан.
                                            • 0
                                              В принципе — да, относится, но, честно признаться, при написании статьи я не знал / забыл про эту фичу с непривязанными делегатами, поэтому написал максимально обтекаемо :)
                                          • +3
                                            Of course, the question is, why didn’t the C# compiler simply emit the call instruction instead? The answer is because the C# team decided that the JIT compiler should generate code to verify that the object being used to make the call is not null. This means that calls to nonvirtual instance methods are a little slower than they could be. It also means that the following C# code will cause a NullReferenceException to be thrown. In some other programming languages, the intention of the following code would run just fine.

                                            using System; 
                                            public sealed class Program { 
                                            public Int32 GetFive() { return 5; } 
                                            public static void Main() 
                                            { 
                                            Program p = null; 
                                            Int32 x = p.GetFive(); // In C#, NullReferenceException is thrown 
                                            }
                                            }
                                            
                                            
                                            Theoretically, the preceding code is fine. Sure, the variable p is null, but when calling a nonvirtual method (GetFive), the CLR needs to know just the data type of p, which is Program. If GetFive did get called, the value of the this argument would be null. Because the argument is not used inside the GetFive method, no NullReferenceException would be thrown. However, because the C# compiler emits a callvirt instruction instead of a call instruction, the preceding code will end up throwing the NullReferenceException.

                                            IMPORTANT
                                            If you define a method as nonvirtual, you should never change the method to virtual in the future. The reason is because some compilers will call the nonvirtual method by using the call instruction instead of the callvirt instruction. If the method changes from nonvirtual to virtual and the referencing code is not recompiled, the virtual method will be called nonvirtually, causing the application to produce unpredictable behavior. If the referencing code is written in C#, this is not a problem, because C# calls all instance methods by using callvirt. But this could be a problem if the referencing code was written using a different programming language.

                                            Richter, Jeffrey (2012-11-15). CLR via C# (4th Edition) (Developer Reference)

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

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