Среди нововведений C# 12 было достаточно больше количество по-настоящему качественных и крутых фич (например дефолтные параметры лямбд).
Но речь сегодня пойдет о ложке дёгтя в бочке мёда - Primary Constructors.
Вот казалось бы, как здесь можно напортачить? Идея взята прямиком из Kotlin, все что надо было сделать это перенести известную, успешно работающую функциональность из одного языка в другой. Всё.
Как говорится, воруй как художник? Думаю, что это не про Primary Constructors, потому что насколько плохо своровать фичу это надо было постараться.
Почему же в Kotlin такие конструкторы работают, а в C# нет? Давайте разбираться.
1. Двусмысленность
На мой взгляд, две самых главных проблемы C# как языка - это многословность и двусмысленность. Под многословностью я подразумеваю возможность получить одинаковый результат разными способами. Под двусмысленностью я подразумеваю конструкцию языка, которая может толковаться по-разному.
Возьмём пример из новенького релиза .NET 8:
// C# 12.0
var t1 = new Test(10);
var t2 = new Test(222222);
// True
t1.Equals(t2)
public struct Test(int A);
Почему мы получаем True
если наполнение структур разное? В случае если параметр Primary Constroctor'а не применяется в свойстве или методе, то он не будет сохраняться как поле. А так как семантика Equals
у структур это value equality, и сравнение происходит по полям - а не сгенерировались, то и результат будет True
.
Получается так, что синтаксис для record
и для struct/class
абсолютно идентичен, 1 в 1, но при этом, генерируемый под капотом код и поведение будет абсолютно другое.
Отлично. Еще одна двусмысленность в и так переполненном двусмысленностью языке.
Но в Кotlin же мы можем пометить поле как сохраняемое! Наверное и в C# 12.0 это можно сделать, ведь при проектировании такой важной фичи был предусмотрен настолько очевидный кейс?
Правда ведь? Конечно же нет!
// Возможно новый кейворд? Не, таких не существует
public class Foo(var int Bar); // нет
public class Foo(val int Bar); // нет
public class Foo(field int Bar); // нет
Так вот, почему это плохо. Потому что это максимально неинтуитивно. Каждый элемент двусмысленности добавляет путаницу, добавляет куски информации, про которые необходимо всегда помнить, чтобы эффективно писать на языке.
Теперь каждый разработчик на шарпе обязан знать 2 новых факта:
Если ты не используешь параметр Primary Constructor'а, то он не будет сохраняться как поле в объекта в типе
Синтаксис
class A(int B)/struct A(int B)
иrecord A(int B)
- это не одно и то же. В случаеstruct/class'ов
этоPrimary Constructor
, в случаеrecord
- это НЕ так.
Если этого не знать, то это прямая дорога сломанному проду. Не написал тест? Использовал структуру с Primary Constroctor'ом и не знал про нюанс сохранения полей? Лови инцидент.
2. Иммутабельность параметров & Уровни доступа
Что происходит с прихраниваемыми значениями primary constructor
'ов? Они становятся неизменяемыми?
Правда ведь? Конечно же нет!
public class Foo(int bar)
{
public int Calc => bar * bar;
// Мы можем мутировать параметр Primary Constructor'а внутри метода
public void Mutation() {
bar =* 2;
}
}
Но в Кotlin же мы можем пометить параметр конструктора как иммутабельный! Наверное и в C# 12.0 это можно сделать, ведь при проектировании такой важной фичи был предусмотрен настолько очевидный кейс?
Да? Да??? Конечно же НЕТ!
// Ошибка компиляции - readonly нельзя
public class Foo(readonly int A)
{
public int C => A*A;
}
А что насчёт наследования? Смогу ли я использовать параметр primary constructor'а из наследуемого класса?
Конечно же нет! Все аргументы primary constructor'ов исключительно приватные:
public class Foo(int foo) : FooBase(foo)
{
// Cannot resolve symbol 'bar'
public int DoStuff => bar
}
public abstract class FooBase(int bar)
{
// ... some code
}
Но в Кotlin же мы можем добавлять уровни доступа к параметрам конструктора! Наверное и в C# 12.0 это можно сделать, ведь при проектировании такой важной фичи был предусмотрен настолько очевидный кейс?
Правда ведь?!
Давайте на 1. 2. 3 - Конечно же .НET!
// Ошибка компиляции - Unexpected token
public class Foo(public int Foo) : Foo2322(foo)
{
// Cannot resolve symbol 'bar'
public int DoStuff => bar
}
// Ошибка компиляции - Unexpected token
public abstract class FooBase(protected int bar)
{
// ... some code
}
3. Многословность и Иммутабельность полей
Помогите Даше ПутеШарпишечнице найти все способы создать иммутабельные поля в C# 12.0! Я смог придумать целых восемь разных способов. Поправьте меня если я что-то упустил:
Ящик пандоры
// 1 - primary constructor + private init;
public class Variant1(int bar)
{
public int Bar { get; private init; } = bar;
}
// 2 - primary constructor + getter only;
public class Variant2(int bar)
{
public int Bar { get; } = bar;
}
// 3 - primary constructor + readonly field
public class Variant3(int bar)
{
public readonly int Bar = bar;
}
// 4 - primary constructor + required init
public class Variant4(int bar)
{
public required int Bar { get; init; } = bar;
}
// 5 - object initializer + required init;
public class Variant5
{
public required int Bar { get; init; }
}
// 6 - default constructor + private init;
public class Variant6
{
public int Bar { get; private init; }
public Variant6(int bar)
{
Bar = bar;
}
}
// 7 - default constructor + getter field;
public class Variant7
{
public int Bar { get; }
public Variant7(int bar)
{
Bar = bar;
}
}
// 8 - default constructor + readonly field;
public class Variant8
{
public readonly int Bar;
public Variant8(int bar)
{
Bar = bar;
}
}
Пример выше, это даже без учета статических фабричных методов. Вместе с ними, в комбинациях можно собрать мать его фуллхаус, а кол-во способов смело на 2 умножать.
При этом, среди вариантов были логически странные способы. Например:
var test = new InitPlusConstructor(b: 1) { B = 2 };
// 1 или 2?
Console.WriteLine(test.B);
public class InitPlusConstructor(int b)
{
public required int B { get; init; } = b;
}
Выводиться будет 2, тут плюс-минус всё понятно, но интересность конструкции на этом не заканчивается. Из-за комбинации кейвордов, получается очень интересная ситуация - мы сможем создать объект, только если оба и конструктор и object initializer будут использованы при создании:
// Ошибка
new InitPlusConstructor() { B = 2 };
// Ошибка
new InitPlusConstructor(b: 1);
// Правильно
new InitPlusConstructor(b: 1) { B = 2 };
Вроде баг мелкий, но все равно неприятно. Я уверен что кто-то уже создал issue для анализатора, я уверен что в одном из первых минорных релизов это пофиксают. Но сам факт остается неприятным
Послесловие
В итоге что мы имеем?
Мы получили какой-то обрубок фичи, в которой:
Нельзя форсить поведение сохранения в поле (как например
var/val
в kotlin)Нельзя выбирать уровень доступа (private, public, protected, etc)
Нельзя форсить иммутабельность значения - все параметры
Primary Constructor
'а являются мутабельными и этого нельзя изменить.Поведение для
record
и неrecord
типов значительно отличается, нет единообразия.Кривые пересечения кейвордов и недоработанный анализатор.
Появляется 4 новых способа инициализации типов и иммутабельных переменных, часть из которых являются логически неправильными.
Появляется 2 новых факта про которые надо всегда помнить при разработке/ревью.
Итак, если собрать все воедино, в чем выражается моя претензия?
Когда речь идет о дизайне языков, я всегда вспоминаю две крайности. Первая - это Го. Элегентно простой, с идеологией "одну вещь делаем одним способом", и мне это нравится.
Другая крайность - плюсы/тайпскрипт, у которых есть 20 разных способов описания цикла. В итоге все сводится к тому, что каждый пишет как хочет, внутри языка появляются диалекты среди разных команд (взять например ту же дилемму выбора type vs interface для описания типов пропсов компонентов в React).
Шарп активно идёт в сторону переусложнения, и на мой личный взгляд, это полная дичь. Я не хочу чтобы это произошло.
Мне хочется думать об архитектуре и домене, но никак о том какой из 7-ми почти идентичных способов использовать для создания иммутабельного типа.
Такие дела.
Комментарии (28)
Yoz75
07.12.2023 17:01+4Вот вроде бы такие синтаксические плюшки должны помогать удобством, но чаще всего просто становятся обузой. С вероятностью в 80% ты её использовать не будешь, а учить надо, ведь кто-нибудь да напишет так, а ты не знаешь. :\
Nurked
07.12.2023 17:01Этот вектор развития стал моей причиной ухода с шарпов. А жаль. Я сдиел на них буквально с детства. Помню ещё фреймворк версии 1.1 и то, каким офигительным тогда казался второй фреймворк. Помню свой первый профессиональный проект, за который я получил первую в жизни зарплату, написанный на MS .NET Framework 2.0 и шарпах. Всё было на ASP.NET со всеми новейшими плюшками. Да, это работало только на Винде и только под IIS, но работало стабильно и круто.
Шарпы ещё тогда славились "многословностью", но она была намного менее многословной чем Ява. Но эта самая многословность отличала шарпы от других (сосбенно скриптовых) языков. Всё было чётко и понятно. У тебя не было двусмысленности. Тебе не надо было перепроверять, что значит знак =, или какого типа у тебя переменные.
Я с большим недоумением смотрел на конструкцию var. Я вообще не понимал, почему её ввели. Для объявлений типа int t = 3 она была достаточно смешной. Для более сложный кейсов, когда тебе надо было объявить Class.Subclass.SubSubClass t = new Class.Subclass.SubSubClass() intellisence отлично справлялся с работой.
Да, понятно, нам хотелось сделать так, чтобы LINQ работал попроще. Поэтому мы начали делать type inference и всё такое.
Самая жесть началась с 2015 года, когда Скотт Хансельман объявил о том, что ASP.NET перешёл в ОpenSource и стал доступен на гитхабе. Шарпы последовали за ним, как и вся платформа. После этого началась дикая каша с названием множества фреймворков и версий продукта. .NET, Core, Mono и все остальные начали беситься.
С этого же времени в шарпы начали добавлять функциональность все кому не лень, в попытках "облегчить" этот язык. Но проблема в том, что ему изначально не надо было быть "лёгким". Он был разработан для взрослых дядек, которые пишут проекты по 4 года, и для который написание hello world занимает 2 недели, чтобы пробиться через всю бюрократию. Но в этом и была прелесть языка, особенно после всех ужасов яваскрипта, где у вас есть var, let и const, и наличиствуют приколы типа == и ===, с добавлением возможностей типа
console.log({} + []); console.log([] + []); console.log({} + {}); console.log({} - []); console.log([] - []); console.log({} - {});
Кстати, не отказывайте себе в удовольствии, вставьте этот код в консоль вашего браузера, и попробуйте предугодать ответы, которые вы увидите на экране.Зачем надо было тратить усилия, для того, чтобы позволить запускать код без int Main()? Ну да, мы получили что-то что ещё больше похоже на Node.js. Но смысл в этом был какой? Каждая IDE для шарпов всегда начинала с того, что создавала Program.cs с int main. Но нам же нужен синтаксический сахар. Мы будем убивать boilerplate.
В шарпах всё было разложено по ящичкам, ящички были разложены по полочкам и приварены к полочкам, полочки были вкручены в стены десятью болтами на 30, стена была железобетонной, толщиной в 10 метров, и установлена на фундаменте в 50 метров в глубину. Сейчас мы начали добавлять "синтаксический сахар" в шарпы. Очень умное название. Ложечка сахара делает кофе намного более вкусным. Две ложечки заставляют тебя задуматься о том, надо ли тебе это. Три - и кофе уже приторный. Мешок сахара - и ты понимаешь что из этой комнаты тебе выходить только вперёд ногами.
Теперь у нас есть какая-то невероятно сложная система работы со switch-case, а классы определяются восемнадцатью различными методами, разница между которыми находится где-то в астрале.
В своё время самым сложным вопросом на собеседовании в шарпах было "расскажи про разницу между public, private, internal, protected и т.п." Сейчас у тебя есть вообще чёрт знает что (как хорошо показано в статье). И всё это сделано для того, чтобы сомнительный паттер dependency injection смог работать. Хотя, жить можно было и без DI. Голанг отлично без него живёт. Зато всё прикручено к полу болтами. Конструкции не имеют неясных значений.
Программирование - это очень детерминированная деятельность. Тебе надо дать чёткие инструкции для процессора, чтобы получить ожидаемый результат. Сейчас инструкции становятся всё более и более размытыми. И самое главное - я не могу понять, почему это происходит.
Что самое интересное, в мире, где все пишут с ChatGPT на пару эта boilerplate вообще не имеет никакого значения. Если тебе влом писать CRUD для класса, настрочи один метод, и попроси бота написать остальное. При этом ты будешь ясно видеть, если бот написал фигню. И это не занимает много времени.
Myclass
07.12.2023 17:01Согласен со всеми ваши словами. Плюс к этому - вроде столько плюшек в студии, столько функций в языке, а открываешь приложения, и любая самая безобидная программа весит как "вчера" - операционная система, база данных и ещё куча чего вместе взятые, и отклик плохой, и зависания постоянно итд.
Gromilo
07.12.2023 17:01+11var нужен для анонимных типов. А анонимные типы нужны были Linq, а без linq шарп не шапр.
Запуск без Main, глобальные юзинги и обобщённая математика нужны для ML. Догоним и перегоним питон типа. Мне ML никуда не упёрся, но и не мешает.
А свич кейсы лично мне нравятся, стало удобнее.
kazimir17
07.12.2023 17:01+1Как же хорошо, что в моем повседневном языке Scala разработчикам вот уже на протяжении многих лет удается сохранять язык стабильным без погони за новыми фичами.
Nurked
07.12.2023 17:01-2Я эту тенденцию заметил за языками и проектам, которыми управляют не с гитхаба а из централизированного коммитета. В ГО генерики приходили десятилетиями, и то только после долгих раздумий. Именно из за централизированного управления язык сохраняет совместимость.
То же самое было с ядром линукса. Пока жив сам Торвальдс, линукс будет относительно стабильным. Но я помню как с пяток лет назад кто-то внёс предложение убрать бинарную совместимость, чтобы "перевести ядро на новые рельсы". Торвальдс тогда такое ответил, что страшно стало.
Проекты живут лучше, когда есть один основатель. (Будь то человек или коммитет) и когда этот основатель следует целям и принципам, которые он установил в начале проекта. Иначе выгодит npmjs.org
mvv-rus
07.12.2023 17:01Ну, судьбе PL/1 совсем не помогло то, что он разрабатывался в рамках одной корпорации. Так что, похоже, "здесь все не так однозначно"(с).
lgorSL
07.12.2023 17:01+2Ну кстати в третьей скале произошли довольно радикальные изменения.
Там и extension методы, которые раньше делались через implicit, и тайкпклассы через given/using (которые раньше были тоже implicit), и inline методы, которых раньше не было (была только аннотация@inline, но она иначе работала).
И вдобавок в третьей скале до сих пор нет и возможно и не будет @speciaized для классов. Именно для них новый inline заменой не будет, в условном Vector[T](x: T, y: T) можно было указать дженерик тип как специализированный, чтобы сгенерировался специальный подкласс, например, с Double полями без боксинга. Конкретно мне эта фича была бы нужна кое-где.
Но правда изменения в скале выглядят на несколько порядков более продуманными и логичными
Primary constructors в C# выглядят как какая-то диверсия, хотя казалось бы просто бери удачное решение из других языков и делай так же.
Bonart
07.12.2023 17:01+4Интересно, кто-нибудь заметит сарказм в вашем комментарии?
kazimir17
07.12.2023 17:01Если говорить про сторонние библиотеки, то да, сарказм тут уместен. Но все ж сам язык 2й версии довольно стабилен.
Bonart
07.12.2023 17:01С тем же примерно успехом я могу ссылаться на стабильность какой-нибудь седьмой версии сишарпа.
А скала стала себе славу языка с ломающими изменениями в минорных версиях
mvv-rus
07.12.2023 17:01+2Вот казалось бы, как здесь можно напортачить? Идея взята прямиком из Kotlin, все что надо было сделать это перенести известную, успешно работающую функциональность из одного языка в другой. Всё.
Как говорится, воруй как художник? Думаю, что это не про Primary Constructors, потому что насколько плохо своровать фичу это надо было постараться.
Как по мне, то в статье неверно изложена причина появления первичных конструкторов (Primary Constructors) в C#
Первичные конструкторы в C# - это не про "своровать фичу" (то есть функцию, если по-русски) из совсем дуругого языка, а про естественное развитие C# в выбранном его создателями направлении.Мой многолетний взгляд на нововведения в C# привел меня к мысли,что это за направление: это увеличение лаконичности языка. Многие добавленные конструкции, такие как var в описателях , new() (без указания типа), код верхнего уровня (не включаемый в методы), определение тел методов, геттеров и сеттеров как стрелочных функций - они бьют именно в эту точку: позволяют сэкономить нажатия на клавиши при написании кода с той же само функциональностью. А заодно - писать меньше строк текста, улучшая обозримость программы. Первичные конструкторы, как они реализованы в C# 12 тоже направлены именно на это, на увеличение лаконичности: они позволяют сэкономить на размере кода - и самого конструктора, и на описаниях внутренних полей, используемых в методах. Потому они и сделаны так, минималистично. И эту задачу они, в целом, выполняют. Для других же задач - типа создания альтернативного способа полноценного, с областями видимости и пр., описания полей объекта - они не предназначены.
Единственный неочевидный выбор, который был сделан при создании этого расширения - это делать ли эти скрыте поля, которые могут понадобиться методам, неизменяемыми. В целом, решение тут неоднозначное, потому что поля в языке C# могут использоваться и так, и так, поэтому надо выбирать. Решение же добавлять возможность выбора, полагаю, было отвергнуто именно из соображений лаконичности. Созадатели языка решили - не делать, вероятно - потому что в C# все эти неизменяемые поля - это более поздние нововведения, а традиционно поля были изменяемыми.Хорошо ли, что C# развивается именно в направлении повышения лаконичности - вопрос дискуссионнный. Лично мне, например, (и, судя по комментариям - не только мне) это не нравится: я умею печать достаточно быстро, чтобы пальцы обгоняли мысли о том, что надо писать, а улучшение обозримости программы, которое могло бы способствовать облегчению понимания ее кода, по-моему, с лихвой компенсируется усложнением языка, которое облегчению понимания кода отнюдь не способствует.
Но я-то программист ненастоящий, т.е. без коммерческого опыта стильномодномолодежной разработки, а потому вполне допускаю, что в области разработки типовых решений для того же веба скорость набора текста вполне может являться ограничивающим фактором (такие мнения я от веб-программистов слышал). А как оно там на самом деле - это тот вопрос, который должен волновать создателей языка: простые разработчики не могут влиять на то, куда пойдет этот процесс.
marshinov
07.12.2023 17:01+1Недавно спрашивал про primary constructors у Медса Торгенсена. Политика такая: они перешли на более частые и более гранулярный релизы. У них несколько вариантов дальнейшего развития primary constructors (и других фичей), но ни одна из реализаций не лишена изъянов. Они выпускают минимальную версию в прод и ждут фидбек. На основе фидбека решают какая из реализаций удовлетворяет большинсиво разработчиков. Так что и блоки инициализаторов и управление уровнями доступа или изменяемостью/неизменностью - все это может появиться в следующих версиях языка. С чего может реально пригореть и что может поменять много идиом C# - это ключевое слово extension, но с ним ясности нет пока.
geoser
07.12.2023 17:01+1Что-то вы как-то все напутали.
В примере 1 у меня получается false, а не true, что я делаю не так? Ваше заявление, что если поле не используется, то оно не сохраняется, не верно.
В примере 2 вы зачем-то смешали primary constructor и immutability, как это связано? Существуют readonly struct, но, вообще-то, никто не обещал, что вместе c primary constructors введут еще и возможность ограничения классов на изменение
По уровням доступа - просто используейте обычные конструкторы, а не primary, всегда можно указать все явно. Primary constructor это синтаксический сахар, который не создан, чтобы покрывать все возможные кейсы полного синтаксиса.
В примере 3 вообще непонятна претензия, init отдельно, primary constructor отдельно. Вы сделали декларацию класса с primary constructor вместе обязательным полем с object initializer и удивляетесь, почему компилятор не позволяет вам использовать только одно из двух, называя это почему-то багом.
В документации же явно написано: primary constructor создает поля, доступные внутри описания класса. Это синтаксический сахар, которые покрывает простые кейсы для сокращения количества кода.
LbISS
07.12.2023 17:01Многое, что делали с языком после С#6-7 - это порча хорошего и стройного языка и превращение его в джаваскрипт. Жабаскрипт такой только по историческим причинам, а тут сознательно портят язык.
Для верхнеуровневых языков скорость разработки зависит не от количества синтаксического сахара или того, пишешь ты конструктор за 30 символов или за 40, а от простоты и наглядности кода, возможностей выражать понятия доменной модели в коде наиболее близким способом, не увеличения, а лимитирования способов сделать одно и то же, ограничения возможностей "писать, что попало" и структурирование всего кода в определённые рамки, близость к натуральным языкам ддя простоты понятия и оперирования...
Основной тех. долг, который несёт косты для бизнеса это ошибки в проектировании доменной модели, макаронный код, проблемы времени жизни объектов и т.п., а не лишняя строка для определения пропсы.
Heggi
ИМХО primary constructor сделан исключительно ради облегчения труда программистов при работе с DI.
Замена простыни
на
многого стоит.
Кстати IDE подчеркивает желтым, если аргументы, объявленные в конструкторе, дальше не используются.
default_g00se Автор
Отчасти согласен, отчасти нет
Возьмем один кейс, который забыл в статью включить - валидационная логика в конструкторе, ее негде писать. Если мы захотим сделать исключение при null значениях параметров, нам снова придется делать обычный конструктор, т.к нет init{} блока.
Также, в вашем примере (2-ая секция кода), сервисы не будут readonly. Да, согласен, маловероятно что их кто-то поменяет, но лично мое субъективное имхо, фича должна сразу грамотно проектироваться, чтобы например кейсы с иммутабельностью учитывать
Heggi
Да, это косяк, но с другой стороны - вы их прописали в конструкторе, зачем их пересоздавать в коде?
Может в дальнейшем пофиксят
Именно в случае работы с DI это лишнее, т.к. DI гарантирует нам наличие запрошенного сервиса в общем случае. Синтаксис с первичным конструктором облегчает кодописание для 99% классов, подключаемых через DI.
В остальных случаях лучше использовать стандартный конструктор.
Makeman
Вроде бы, валидация предусмотрена, а для readonly-свойств в структурах (и readonly-структур) создаются immutable-поля (правда, похоже, в этом механизме пока что есть баг).
OctoSinel
Это ничего не стоит, потому что 90% кодовой базы использует private readonly _field; как codestyle полей, а primary constructors этот codestyle ломают.
Heggi
Что мешает в primary constructors объявить поля как _field? Единственный нюанс, как выше написали, они не будут readonly, но в 99% случаев работы с DI на это по барабану.
ARad
Это бы помогало, если бы их можно было бы сделать только для чтения, а так надо ещё добавлять поля или свойства.
А после добавления полю/свойства у тебя в любом месте есть доступ и к полю/свойству что правильно. Так у тебя все ещё остаётся доступ и параметру первичного конструктора, это прямо бесит, потому что легко перепутать...
В общем получилась какая то какашка, хотя я сначала думал что это будет прямо бомба.
Heggi
В чем принципиальность, чтобы поле было readonly?
Я использую их прямо так, не объявляя лишних полей/свойств.
И желания переобъявить условный DbContext внутри сервиса не появляется.
Heggi
Сейчас прямо попробовал.
Если сделать так, то доступа к параметру первичного конструктора не будет
Костыль, конечно
a-tk
Если он не используется внутри, значит он не нужен. И в таком случае в сравнении нафиг не нужен.
А по поводу отличия class/struct vs record надо читать документацию по языку, а не полагаться на интуицию и предположения из другого мира...