Хочу рассказать вам историю о том, как синтаксический сахар может отнять у вас несколько часов и привести к ошибкам на продакшене. Так же разберу причины самих ошибок и постараюсь найти варианты, как можно было бы их избежать.
Сразу стоит отметить, что история, которую я опишу ниже, произошла с разработчиком из моей команды, а я лишь выступал в качестве наблюдателя.
Синтаксический сахар в C#
В нашей команде используется язык C#, поэтому мой рассказ будет про его синтаксис.
Синтаксический сахар заменяет длинные и часто используемые конструкции кода, делает их более короткими и быстро читаемыми, облегчает и ускоряет разработку.
Здесь стоит отметить и другое мнение: существуют люди, которые считают, что синтаксический сахар только усложняет код, делая его менее читаемым.
Я придерживаюсь данного мной определения, хотя опыт показал, что не всё так однозначно. Разберем на примерах:
Примеры:
Конструкция
new () {1, 2}
заменяетnew List() {1, 2}
-
Оператор
+=
заменяет конструкциюnumber += 1
=>number = number + 1
-
А оператор
??=
вообще имеет долгую историюfoo ??= "Строка была равна null"
=>foo = foo ?? "Строка была равна null"
=>foo = foo is null ? "Строка была равна null" : foo
=>if (foo is null)
{
foo = "Строка была равна null";
}
Эти примеры лишь малая часть всех тех конструкций, которые иcпользуются в C#. С каждой новой версией языка появляется всё больше новых конструкций и нужно пристально следить, чтобы ничего не упустить.
Неожиданные Null Reference Exception
Итак, перейдем к истории. Всё началось с того момента, когда в нашем проекте начали "выстреливать" ошибки NRE. По стек-трейсу было ясно, что код "падает" при инициализации класса. Ниже представлен фрагмент кода, приближенный к тому, что был у нас в проекте.
...
var result = new ExampleClass
{
ExString = "Тут не должно быть взрыва",
ExList = { 0, 1 },
ExString2 = "И тут",
ExInt = 24
};
...
NRE выбрасывалось на строке var result = new ExampleClass
, которая, в теории, не должна вызывать такую ошибку. Отсюда стало очевидно, что следует обратить внимание на то, как инициализируются поля объекта.
Вот как выглядел класс, который мы пытались инициализировать:
public class ExampleClass
{
public string? ExString { get; set; }
public List<int>? ExList { get; set; }
public string? ExString2 { get; set; }
public int ExInt { get; set; }
}
Наверное, я оставил очень прозрачные подсказки в коде, чтобы вы смогли догадаться, из-за чего выбрасывалась ошибка. Но в моменте было совсем не ясно, особенно когда ты пытаешься быстро разобраться в ситуации из-за ошибок на продакшене.
Пояснение
Можно обратить внимание на весьма странный синтаксис в коде.
...
var result = new ExampleClass
{
...
ExList = { 0, 1 },
...
};
...
Выше я приводил пример того, как конструкция new() {0, 1}
заменяет new List() {0, 1}
. Можно заметить, что {0, 1}
уж очень похоже на new() {0, 1}
.
Первый раз, глядя на эту конструкцию, в голову совсем не приходит мысль о том, что тут что-то не так. Скорее возникает мысль: "Наверное, это новый синтаксис". После 10 минут интенсивного просмотра кода было принято решение заменить {0, 1}
на new() {0, 1}
. И, о чудо, ошибки исчезли.
Но причём тут NRE? А всё дело в том, что{0, 1}
заменяет конструкцию .Add()
И вот эти два блока кода оказываются идентичными:
// 1
var result1 = new ExampleClass
{
ExList = { 1, 2 },
ExString = "Привет",
ExInt = 24
};
// 2
var result2 = new ExampleClass
{
ExString = "Привет",
ExInt = 24
};
result.ExList.Add(1);
result.ExList.Add(2);
Так как ExList
при инициализации равен null
, то при попытке вызывать null.Add()
мы получаем NRE.
Почему такое произошло?
Лично я вижу здесь несколько причин. Во-первых, в нашей команде и вокруг неё этот синтаксис не так популярен, с ним редко кто сталкивается. Во-вторых, IDE никак не подсвечивает такую простую NRE, хотя поле было помечено как nullable, и такое можно было бы подсветить.
Как этого избежать?
Самое главное — быть внимательным и проверять код. Можно попробовать следить за актуальными конструкциями, но, по моему опыту, это сделать весьма проблематично. Как ни странно, хоть IDE в этом случае подвело, но оно все равно умеет замечать большинство таких проблем. Именно поэтому стоит прислушиваться к его предупреждениям.
Заключение
В заключении хочу сказать, что несмотря на такие ситуации, синтаксический сахар остается мощным инструментом и приносит больше пользы, чем вреда. Чтобы избежать таких проблем, важно следить за глубоким пониманием того, как работает ваш код, а не только за тем, как он выглядит. Регулярный код-ревью и обмен опытом с коллегами помогут вам выявлять и предотвращать подобные ошибки заранее.
P.S.
Это одна из первых моих статей на Habr, и я был бы благодарен за вашу конструктивную критику и обратную связь.
Комментарии (51)
ColdPhoenix
30.09.2023 17:07+4VS2022 подствечивает там потенциальную ошибку.
Может раньше не было правда.
chkaff Автор
30.09.2023 17:07Спасибо, что протестировали это в своем IDE. В данной ситуации использовался Rider. Подробнее написал тут: https://habr.com/ru/articles/764586/#comment_26015534
Очень круто что сейчас ошибки подсвечиваются.
impwx
30.09.2023 17:07+2Вроде все логично: конструкция с фигурными скобками разворачивается в серию последовательных вызовов
.Add()
на коллекции, и нововведением это не назовешь - кажется, еще с версии C# 5.0 доступно. Иногда эта возможность действительно удобна, например когда поле является get-only:class Foo { public List<int> A { get; set; } = new List<int>(); public List<int> B { get; } = new List<int>(); } var foo = new Foo { A = new List<int> { 1, 2 }, // работает // B = new List<int> { 3, 4 } // не сработает - ошибка компиляции B = { 3, 4 } // сработает };
А вот то, что ошибка показывается на первой строке выражения, а не на той где действительно указана вызывающая ошибку операция - это досадная недоработка.
NeoCode
30.09.2023 17:07+24Ну не знаю, в моем понимании ExList = { 1, 2 } это присваивание объекту некоего составного литерала, а никакие не Add. Так что создатели C# здесь сделали фигню. Добавление там new не изменяет семантики всего выражения - это по прежнему присваивание объекту другого объекта.
impwx
30.09.2023 17:07+2Если пытаться прочитать код на языке X, опираясь на интуицию и знание какого-то другого языка Y, то абсолютно любая фича языка X может показаться странной. Наверное, программисту на PHP покажется, что
foo.bar
- это конкатенация двух строк, а не обращение к методу, и так далее. Такой подход едва ли конструктивен.В сишарпе добавление
new
меняет-таки семантику, потому чтоnew { ... }
- это выражение, иnew () { ... }
- это выражение, а вот просто{ ... }
- нет. Его нельзя сохранить в переменную или передать в функцию. Соответственно, когда вы пишете справа от знака равно что-то, что не является выражением, это не может быть обычным присваиванием и должно быть чем-то еще - в данном случае, инициализацией коллекции через последовательные вызовы методаAdd
.Если у вас есть идеи, как можно было бы описать инициализацию вложенной коллекции лучше - поделитесь, пожалуйста.
nin-jin
30.09.2023 17:07ExList ~= { 1, 2 }
Ну или как там у вас конкатенация обозначается.
boldape
30.09.2023 17:07+8ExList += { 1, 2 } // add semantic
ExList = { 1, 2 } // assign semantic
Мне кажется как то получше чем то что есть. + это должно работать как то так для присваивания
ExList = { 1, 2 } =>
if ExList.IsNull () { ExList = new() { 1, 2 } }
else { ExList.Clear(); ExList = { 1, 2 } }
И как то так для добавления
if ExList.IsNull() { ExList = new() {1, 2 } }
else { ExList.Add(1); ExList(2); }
Я не очень представляю в каком случае будет иметь смысл НЕ инициализировать объект заданными значениями если он нулл, а кидать исключение как сейчас делает такой синтаксис, ну и явный += как то заметней чем разное поведение в зависимости от отсутвия наличия new ()
Вообще как раз мнение людей не пишущих на этом языке о синтаксисе часто менее предвзяты чем мнение тех кто пишет на нем каждый день, так что зря товарища выше минусят.
mayorovp
30.09.2023 17:07+2Если сделать так, то снова получается неконсистентность — при инициализации оператор += есть, а отдельно его нету. А если сделать его отдельно, то снова беда: один и тот же оператор вызывает то
operator +
, то метод.Add()
в зависимости от того что справа. А сделать всё через один метод нельзя — потому что{1, 2}
не литерал объекта, а просто синтаксис для инициализации коллекции.boldape
30.09.2023 17:07если сделать его отдельно, то снова беда: один и тот же оператор вызывает то
operator +
, то метод.Add()
в зависимости от того что справа.А в чем собственно беда? Сахар он на то и сахар что бы вот такие неконсистености скыравать за простым синтаксисом, ну или я не понял о чем вы.
NooneAtAll3
30.09.2023 17:07снова беда: один и тот же оператор вызывает то operator +, то метод .Add() в зависимости от того что справа
возможно тут написано что-то на джававском, но в моем понимании одно должно вызывать другое… неужели в шарпах не так?
mayorovp
30.09.2023 17:07А сделать всё через один метод нельзя — потому что
{1, 2}
не литерал объекта, а просто синтаксис для инициализации коллекции.
chkaff Автор
30.09.2023 17:07Спасибо за полезный пример применения, он добавляет ясности. Да, действительно, это нельзя назвать нововведением, но в тот момент людей знающих этот синтаксис не оказалось рядом и для меня это было чем то новым.
Krawler
30.09.2023 17:07+7Конструкция new() без указания типа вообще была введена как аналог var, но для R-value
То есть можно написать либо:
var l = new List<int>(){1, 2, 3};
либо
List<int> l = new(){1,2,3};
Но причём тут NRE? А всё дело в том, что
{0, 1}
заменяет конструкцию.Add()
И вот эти два блока кода оказываются идентичными:А это так вообще первый раз слышу. И IDE почему-то тоже не в курсе
chkaff Автор
30.09.2023 17:07+1Да, действительно, это не работает с обычным
Add
. Эта конструкция используется только при инициализации объекта. Выше подсказали хороший пример использования: https://habr.com/ru/articles/764586/#comment_26015170.
Nurked
30.09.2023 17:07+2Вот именно из-за всего этого я ушёл с C#. Писал на нём с 2003 года. Помню как вышел .NET 1.1. Помню как было круто с дженериками в .NET 2.0.
Я прилежно учился и следил за всеми новыми выпусками .NET. Я помню тот день, когда Скотт Хансельман показал гитхаб для ASP.NET.
Я думал о том, как будет круто когда сам шарп превратился в по-настоящему открытый язык. Но нет. Это его просто убило в моих глазах.
Я вообще не понимаю, зачем надо было смешивать строго типизированный язык с Яваскриптом и чисто функциональными языками. Что не так с MyClass c = new MyClass()? Учитывая с тем, что всё на ватокомплите и после набора первых трёх быкв вижуал студия [и любая другая ИДЕ] могла всё автозаполнить. Неочевидно, но наличие var эту фитчу отключило. В любом случае, что ты будешь делать с сэкономленными фемтосекундами?
После того, как самый простой switch-case превратился в тьюнинг-полного монстра я ушёл в мир голанга.
Все эти свистоперделки и рюшечки превратили отличный язык в перл. Сейчас код на шарпах выглядит одинаково до и после шифрования файлов исходника.
insighter
30.09.2023 17:07думаю вы ушли на голанг потому, что за него больше платят
Nurked
30.09.2023 17:07+3С таким подходом вы не заработаете денег. Сейчас я вообще пишу на ноде, потому что этого требует бизнес. Я - профессиональный программист. Это значит, что я не промываю мозги своему начальнику рассказами о том, как хорош или плох какой-то ЯП. Я просто беру проблему, которую нужно решить для бизнеса и решаю её без рассказов о том, на каком языке она пишется. Я просто решаю проблемы. За это платят намного больше, чем за рассказы о хороших языках.
insighter
30.09.2023 17:07+2Примечательно, что безобидное высказывание вызвало у кого-то негатив :)
Работодатели всё-таки ищут не просто [профессиональных] разработчиков, а тех у кого есть опыт с определенным стеком. Нельзя написать в резюме "я решаю задачи бизнеса" и стать нарасхват.
Перекосы в разных стеках есть - не в разы конечно, но всё же C#/.NET никогда не славился большими ЗП.
С таким подходом вы не заработаете денег.
Здесь я бы не стеки новые учил, а масштабировал свои усилия для большего кол-ва результата -- шел в бизнес и т.п.
aegoroff
30.09.2023 17:07+2Поддерживаю - постоянное добавление новых возможностей потихоньку превращает его в C++ в котором можно сделать одно и тоже
отстрелить себе ногу1001 способом. Как мне кажется, на версии 8 вполне можно было остановиться - выразительности более чем достаточно, а дальнейшее добавление новых фич будет лишь во вред
impwx
30.09.2023 17:07+1зачем надо было смешивать строго типизированный язык с Яваскриптом и чисто функциональными языками
Тут можно дать очень обширный ответ, но если в кратце - то потому, что так удобнее. Например, работа с коллекциями через LINQ гораздо лаконичнее, удобнее и нагляднее, чем если писать все то же самое на циклах. Кроме того, некоторые особенности функционального стиля (чистые функции, иммутабельные переменные, композиция функций) позволяют легче распараллелить и отлаживать код.
Что не так с MyClass c = new MyClass()?
Да всё в порядке. Если визуальный шум вам не мешает, то пишите на здоровье. В стайлгайдах Microsoft принято в большинстве случаев указывать тип, если только он не очевиден по выражению справа, как у вас. Но многим людям это кажется избыточным, поэтому они используют `var`.
самый простой switch-case превратился в тьюнинг-полного монстра
Опять же, обычный switch-case остался как есть. Вам никто не запрещает писать так, как вы писали во времена 15 лет назад во времена .NET 2.0. Лично мне новый switch expression тоже не нравится, я считаю его избыточным и плохо вписывающимся в язык, поэтому я им практически никогда не пользуюсь. Портит ли его наличие язык в целом? Едва ли, хотя разработчики инструментария (Resharper, Rider, статических анализаторов и т.д.), которые вынуждены это поддерживать, наверняка со мной не согласятся.
Вывод - язык развивается в ту сторону, в которую его толкают пользователи. Выходит так, что большинство людей хочет писать лаконичный, но в то же время понятный код. Эта золотая середина у каждого своя, поэтому то, что дизайнеры языка C# не попали лично в вашу - не особо удивительно. В то же время, вы нашли язык, который вам больше по душе, что очень здорово.
Nurked
30.09.2023 17:07Проблема не в том, что я могу это всё пропускать мимо ушей. Проблема в том, что я работаю с другими людьми, и код, который ко мне приходит выглядит как регекс, а не как код. Другие-то люди пишут на этом.
impwx
30.09.2023 17:07+3С технической точки зрения все решаемо: поставьте минимальный langversion в проекте, настройте codestyle на свое усмотрение с помощью Stylecop, Resharper, Roslyn Analyzers - на здоровье. Останется только найти людей, которые согласятся в таких условиях работать
develmax
30.09.2023 17:07+2Дело в том, что если вы должны писать тону кода и развивать огромный проект, то switch expression позволяет писать то же самое, но быстрее - это здорово экономит время и превращает сотню if в элегантную конструкцию, и становится проще анализировать код, который занимает много места. В то же время бывают спорные решения, но благо развитие языка открыто и можно предлагать свои или обсуждать предложенные другими функции на github.
impwx
30.09.2023 17:07Мне не нравится, что switch expression служит той же цели, но отличается от switch statement практически во всем: ключевое слово
switch
пишется после выражения а не до, вместоdefault
используется_
, вместоcase
- стрелка=>
, и самое важное - не проваливается на следующий вариант при отсутствииbreak
.Вместо этого можно было все унифицировать:
Писать
switch
всегда перед проверяемым выражением. Разделять на switch expression и switch statement по тому, располагается ли оно внутри выражения или нет.Разрешить
X => Y
в switch statement как синонимcase X: Y; break;
, по аналогии с expression body в свойствах и функцияхРазрешить
_
как синонимdefault
Не делать
case > 1
, а использовать более общийcase var x when x > 1
Самое досадное, что в Java это сделали как надо, а в C# не смогли.
mvv-rus
30.09.2023 17:07+1Кроме того, некоторые особенности функционального стиля (чистые функции, иммутабельные переменные, композиция функций) позволяют легче распараллелить и отлаживать код.
Это вы про C# или вообще?
Если про C# то покажите мне способ получиь гарантию от компилятора, что делегат, который мне могут передать в мою функцию, не может не быть чистой функцией. Я такого способа, например, не знаю. А в отсутствии этой гарантии от компилятора функциональное программирование превращается в «функциональщину», которая ничего такого прекрасного не позволит.impwx
30.09.2023 17:07Вообще я говорил в целом о подходе. Чистоту функций обычно связывают с ФП, потому что понятие "оттуда родом", но оно вполне неплохо ложится и на императивный код. То, что компилятор не может автоматом это гарантировать, возможно несколько ограничивает его удобство, но не перечеркивает его целиком. LINQ, например, используется повсеместно, и я не слышал особых жалоб на то, что возможность передать туда лямбду с побочными эффектами рушит всю идею. Наоборот, если это локально и вы понимаете что делаете, то может быть даже удобно - например, вставить логирование посреди длинной цепочки вызовов.
hVostt
30.09.2023 17:07+4Какие-то смузи-стайл аргументы, уж извините. Я ушёл из C# потому что, всё не так, я думал оно вот так, а оно вон оно как, не так и не эдак. Зачем так надо было-то? То то, то это, не то ни это, монстр какой-то.
Сейчас код на шарпах у нас решает множество задач. При чём производительных, под высокими нагрузками с весьма скромными требованиями. Наблюдаемость у проектов на C# феноменальная. С чем только и как не интегрировались, работает как часы.
Когда в Go внезапно что-нибудь добавят, и там найдётся свой процент ненавистников "свистоперделок", которые уйдут на что-нибудь очередное хайповое. Ох уж эти свистоперделки, никакого спасу от них нет :)))))
dopusteam
30.09.2023 17:07+2Имхо, во всех примерах проблема не с сахаром, а с кодом
kchhay
30.09.2023 17:07+2Есть теория, что "хороший интерфейс должно быть сложно использовать неправильно". То есть, даже если вы используете сахар, неправильный код должен выглядеть инородно, неправильно, вызывать подозрения. Я не шарпист, для меня то, как оно было написано - выглядело целиком нормально. Автор статьи - шарпист. И для него это тоже выглядело нормально. Если неправильное использование синтаксиса выглядит настолько органично, то, на мой вкус, проблема все же в синтаксисе, а не в использующем коде.
dopusteam
30.09.2023 17:07var result = new ExampleClass { ExString = "Тут не должно быть взрыва", ExList = { 0, 1 }, ExString2 = "И тут", ExInt = 24 };
Смотрите, автор указал такой код.
Если я напишу без сахара, типа как
var result = new ExampleClass(); result.ExList.Add(0); result.ExList.Add(1);
То он так же развалится, тут не в сахаре дело.
Плюс эта ошибка всегда подсвечивается IDE, о чём автору сообщили в самых первых комментариях (не знаю, в чём он код пишет), а если ещё и настроить всё правильно, то можно даже ошибку компиляции получить.
kchhay
30.09.2023 17:07Признаться, я не очень понимаю, к чему вы ведете. В коде используется синтаксический сахар. Из-за использования сахара получается то, о чем я (да и вы, в общем-то) сказал - выглядящий совершенно нормально код содержит в себе проблему. Если убрать сахар - мы увидим код, который совершенно очевидно содержит в себе ошибку. То есть, проблема не в исходном синтаксисе языка, а в синтаксическом сахаре.
Хотя, зная о подобных выкрутасах, я бы, наверное, допустимый на проекте сахар как-то ограничивал и тогда это можно считать проблемой кода, да.
Само собой, комментарии об IDE совершенно логичны. Еще можно настроить синтаксический анализатор.dopusteam
30.09.2023 17:07+1Из-за использования сахара получается то, о чем я (да и вы, в общем-то) сказал - выглядящий совершенно нормально код содержит в себе проблему
Но ведь и без сахара код выглядит совершенно нормально
Признаться, я не очень понимаю, к чему вы ведете
К тому, что, очевидно, нужно знать, что делает та или иная конструкция в языке, будь то сахар или нет.
А вся история автора выглядит очень странно, начиная от отсутствия подсказок IDE и заканчивая
Всё началось с того момента, когда в нашем проекте начали "выстреливать" ошибки NRE.
Очевидно, что код из статьи начал бы сыпаться при первом же запуске, а не неожиданно начал выстреливать где то. Либо автор чего то недоговаривает, либо намеренно упростил код и там вокруг ещё куча не очень хорошего кода. Такой код должны были обнаружить сначала при разработке, потом на ревью, а потом и при тестировании (я всё таки предполагаю, что 'в нашем проекте начали "выстреливать" ошибки NRE' - это где то на проде или на тестовом контуре, а не при локальной разработке).
Собственно, я и веду к тому, что автор на очень неудачном примере пытается показать, что сахар плохой. А плохой тут не сахар, а код, вот и всё.
По поводу ограничения сахара - мне кажется тут всё таки решается всё культурой разработки в первую очередь.
navrocky
30.09.2023 17:07+1А разве включение nullable начиная с 8 версии не спасает?
chkaff Автор
30.09.2023 17:07Спасает, но в тот момент Rider не выкинул предупреждения. Сейчас Rider работает корректно, и, как оказалось, другие IDE тоже.
develmax
30.09.2023 17:07Да, нужно включить везде nullable и настроить правильно IDE и компилятор, так чтобы при возникновении null где-либо - такой код не компилировался.
Bagir123
30.09.2023 17:07+1Синтаксический сахар упрощает написание кода, но никто не мешает писать без него, если есть такая проблема.
Единственное разбирать чужой код из сахара проблема.
Допустим мне бы в голову не могло прийти что такое вообще возможно на c#: (a, b) = (b, a). Это обмен значений между переменными, кто не в курсе.
Mirual
30.09.2023 17:07Забавно, я думал, что только в питоне идут споры про приемлемость применения list comprehension и подобных сокращений/улучшений/ухудшений кода, а оказывается нет)
Heggi
У вас какая-то странная IDE. У меня подсвечивает (vscode)
s207883
Rider тоже такое не пропустил
Hidden text
Обмажутся своими блокнотами, а потом в ноги стреляют!
chkaff Автор
Вы абсолютно правы, сейчас Rider подсвечивает эту ошибку. Это был не блокнот, а Rider, хотя я не могу сказать точную версию. Либо эта ошибка исправилась с обновлениями, либо были проблемы с системой подсказок локально.
iBljad
Недавно сталкивался с тем, что мне Idea 2023 ultimate (не знаю, что важнее из этих двух) подсвечивала ошибку, а коллеге в версии 2022 community — нет. Так что вполне можно быть, что и в новых версиях Райдера добавили проверок.
amalinin
Я думаю, что не IDE должна показывать такие ошибки, а компилятор должен запрещать компилировать подобный код.
chkaff Автор
Кажется, что в IDE должна быть настройка, позволяющая превращать определенные предупреждения в ошибки компиляции.
zetroot
TreatWarningsAsErrors
Это директива компилятора, включается в файле проекта.
Krawler
Del