Среди нововведений 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-ми почти идентичных способов использовать для создания иммутабельного типа.
Такие дела.