Когда несколько лет назад в первый раз увидел использование первичных конструкторов (primary constructors) в языке Typescript, то всё ждал появления аналогичной конструкции и в C#. Ведь можно тогда будет не инжектить в конструкторе каждую зависимость.
Как и многим коллегам, мне "зашла" новая фича 12-ой версии языка C#, однако были и противники нововведения. Один из разработчиков сетовал на неудобства и приговаривал "кто это придумал, того надо избить".
Так ли всё плохо? Давайте подробно разбираться!
Осторожно - лонгрид!
Введение
Если вспомнить историю новинок языка, то впервые первичные конструкторы в C# появились не в 12-ой версии, а ещё в 9-ой. И были они представлены как часть функциональности записей (record). Чтобы создать тип Human до версии C# 12 нужно было бы написать такой класс:
public class Human
{
public string FirstName { get; }
public string SecondName { get; }
public int Age { get; }
public Human(string firstName, string secondName, int age)
{
FirstName = firstName;
SecondName = secondName;
Age = age;
}
} Можно отметить, что присутствует много дублирования. И это ещё небольшой класс. А теперь представьте, что у вас есть сервис с непростой бизнес-логикой, и он инкапсулирует работу множества операций: проводит валидацию данных, проверяет привилегии пользователя, обращается к другим сервисам за вспомогательными данными и т.д. В итоге в таком классе сервиса могут быть объявлены несколько readonly-полей, конструктор с параметрами и инициализация этих же полей в конструкторе, что будет выглядеть весьма ёмко. Как раз целью первичных конструкторов и является упрощение определения и уменьшение дублирования.
Способы использования параметров первичного конструктора:
- Инициализировать члены (или передать их базовому конструктору).
- Использовать прямую ссылку на параметры.
Разберём примеры использования:
1. Инициализация членов параметрами
Инициализация свойств напрямую аналогична определению класса, показанному выше, но уменьшает часть дублирования и, как правило, более компактна:
public class Human(string firstName, string secondName, int age)
{
public string FirstName { get; } = firstName;
public string SecondName { get; } = secondName;
public int Age { get; } = age;
}Мы почти вдвое сократили количество кода, при этом сохранив его намерение понятным и очевидным!
Заметьте, здесь мы используем классы, но для структур всё будет так же. Аналогично можно задать и приватные поля:
public class Human(string firstName, string secondName, int age)
{
private readonly string _firstName = firstName;
private readonly string _secondName = secondName;
private readonly int _age = age;
public string FullInfo => $"{_firstName} {_secondName}, возраст {_age}";
}Но первичные конструкторы позволяют убрать определения полей совсем, и это рассмотрим ниже:
2. Использовать прямую ссылку на параметры.
Если значения приватных полей не используются нигде, кроме свойства FullInfo, мы можем использовать первичный конструктор, чтобы ещё больше «упростить» класс:
public class Human(string firstName, string secondName, int age)
{
// Напрямую ссылаемся на
// параметры первичного конструктора
public string FullInfo => $"{firstName} {secondName}, {age}";
}
Но можно пойти и дальше и изменять параметры первичного конструктора:
public class Human(string firstName, string secondName, int age)
{
…
public void SetInfo(string first, string second, int newAge)
{
firstName = first;
secondName = second;
age = newAge;
}
}
Если выполнить следующий код, можно увидеть, что параметры конструктора изменяемые:
var man = new Human("Pavel", "Stepanov", 30);
man.SetInfo("Andrew", "Lock", 41);
Console.WriteLine(p.FullInfo); // "Andrew Lock, 41"
Теперь посмотрим подробнее, как дела обстоят "под капотом".
Отличный способ понять многие новые функции C#, особенно те, которые по сути являются синтаксическим сахаром, — использовать Sharplab.io, чтобы увидеть, как компилятор «понижает» функции до более простого C#.
В примере, где мы инициализировали приватные поля через первичный конструктор, компилятор просто сгенерировал код обычного конструктора за нас код обычного конструктора за нас.
А вот если мы используем параметры первичного конструктора в членах класса, код будет очень похож, но всё же отличаться код будет очень похож, но всё же отличаться.
Если ссылаться на параметры основного конструктора где-либо, кроме инициализатора поля или свойства, компилятор автоматически сгенерирует поля с невалидными в обычном C# именами, например:
private string <firstName>P;
для хранения значений параметров, поэтому их можно использовать в других методах и свойствах. Это одна из важных особенностей первичных конструкторов классов.
Добавление проверки к инициализации
Одной из общих проблем, связанных с такими функциями, как первичные конструкторы, является проблема «роста». Есть риск, что функция отлично работает в простых случаях, но как только вам нужно что-то более сложное, приходится полностью от неё отказаться.
Например, в конструкторы обычно добавляют код проверки на null или недопустимость аргументов. В первичных конструкторах их по-прежнему можно использовать:
using System;
public class Human(string firstName, string secondName, int? age)
{
private readonly string _firstName =
firstName
?? throw new ArgumentNullException(nameof(firstName));
private readonly string _secondName =
IsValidName(secondName)
? secondName
: throw new ArgumentNullException(nameof(secondName));
private readonly int _age = age
?? throw new ArgumentNullException(nameof(age));
public string FullInfo => $"{_firstName} {_secondName}, {_age}";
private static bool IsValidName(string name)
=> name is not "";
}И вот таков будет результат компиляции результат компиляции.
Конечно, с этим кодом инициализации можно зайти далеко, и в определённый момент, вероятно, имеет смысл отказаться от первичного конструктора и использовать обычный. Стоит отметить, что вы можете добавить проверку параметров конструктора, только если вы используете их для инициализации, но не если вы ссылаетесь на них напрямую.
Теперь мы должны разобрать главное: а стоит ли их использовать? Прав ли один из моих коллег?
Лучшие варианты использования
Рассмотрим случаи, для которых первичные конструкторы хорошо подходят.
1. Базовая инициализация поля
Как было разобрано выше, первичные конструкторы сокращают объём кода, который нужно написать, и вместо этого компилятор генерирует этот код за разработчика:
public class Human(string firstName, string secondName)
{
private readonly string _firstName = firstName;
private readonly string _secondName = secondName;
}Однако, даже добавление валидации уже делает код не таким очевидным:
public class Human(string firstName, string secondName)
{
private readonly string _firstName =
!string.IsNullOrEmpty(firstName)
? firstName
: throw new ArgumentException(
"Must not be null or empty", nameof(firstName));
/////
}
Вы не можете использовать защитные методы, доступные в .NET 7+:
ArgumentException.ThrowIfNullOrEmpty(firstName);
и уже одно это делает код стандартного конструктора более сжатым и понятным. Понятно, что можно создать методы-помощники и сократить количество кода, продолжая использовать первичные конструкторы, но зачем?
2. Инициализация в тестовом коде
Возьмем простой пример теста xunit для типа Human:
public class HumanTests(ITestOutputHelper output)
{
[Theory]
[InlineData("Pasha", "", 30)]
public void Human_throws_if_null_or_empty(
string firstName, string secondName, int age)
{
output.WriteLine(
$"Testing '{firstName}', '{secondName}' and {age}");
Assert.Throws<ArgumentException>(() =>
new Human(firstName, secondName, age));
}
}Опустим тут ценность этого теста, и нужно ли нам использовать ITestOutputHelper в методе. Здесь интересно то, что добавление ITestOutputHelper в качестве параметра первичного конструктора упрощает тестовый класс. Для теста не имеет значения, хранится ли ITestOutputHelper в поле или нет, и является ли он изменяемым.
3. Внедрение зависимостей в контроллерах MVC
В ASP.NET Core вероятно использование внедрения зависимостей и сложно проверять зависимости, которые внедряются в контроллеры. Это делает их хорошими кандидатами для первичных конструкторов (можно даже использовать значения напрямую):
public class MyController(
ILogger<MyController> logger,
IService service) : ControllerBase
{
[HttpGet]
public ActionResult<Thing> Get()
{
logger.LogInformation("Get the thing");
return service.GetThing();
}
}Очевидно, что это коротко и ясно. Но имейте в виду, что теперь зависимости изменяемы, и им можно назначать значения в теле контроллера, даже если вы, скорее всего, никогда не захотите этого делать.
Фактически, вы, вероятно, обнаружите, что большая часть ежедневного кода, который вы пишете в своём приложении, может использовать первичные конструкторы без какого-либо существенного влияния, кроме сокращения строк кода.
Тем не менее, при использовании первичных конструкторов есть вещи, на которые стоит обратить внимание. И об этом пойдёт речь далее.
Подводные камни
Замечание: описанные ниже «проблемы» не означают, что нельзя использовать первичные конструкторы, и для вас они даже могут показаться несущественными.
1. Повторный захват
Если вы инициализируете поле с помощью параметра первичного конструктора, а также используете его значение в другом члене класса, у вас будет два поля, хранящих одно и то же значение:
public class Human(string name)
{
private string _name = name;
public string Greeting
=> $"Hello, {name}";
}
Эта проблема настолько очевидна, что компилятор выдаст предупреждение, если вы сделаете это, так что тут особо беспокоиться не о чем.
2. Неявные поля не могут быть readonly
Если вы неявно захватываете параметр первичного конструктора, компилятор генерирует поле для его хранения. Важно, что они изменяемы, т.е. параметру (и этому полю) можно задавать значения в теле класса. Если это то, что вам нужно, нет проблем. Но если поле не должно быть изменяемо, обычно стоит отметить его как readonly, что упрощает понимание назначения поля и устраняет целый класс ошибок, связанных со случайным изменением поля. Не удивлюсь, если это исправят в будущих версиях C#, но сейчас это немного раздражает. Решение в том, чтобы не использовать неявный захват, если вам нужны поля только для чтения.
3. Путаница в соглашениях об именах
Не большая проблема, но это придётся обсудить в команде на раннем этапе: какие соглашения следует использовать для параметров первичного конструктора? Использовать ли для параметров префикс _? Если вы используете неявный захват в коде класса, то там они будут по сути как приватные поля:
public class Human(string _firstName, string _secondName, int _age)
{
public string FullInfo => $"{_firstName} {_secondName}, {_age}";
}
С другой стороны, создание экземпляра такого класса будет выглядеть немного странно:
var h = new Human(_firstName: "Pasha", _secondName: "Stepanov", _age: 30);
Microsoft рекомендует именование как параметров (без префикса). Кстати, вы не можете использовать this. в коде для обращения к параметрам первичного конструктора, такой код просто не скомпилируется.
4. Неявные поля меняют размер структур
В высокопроизводительном коде или при работе с interop иногда бывает важен размер экземпляров и как выстроено их содержимое. Если у нас есть следующая структура:
public struct Human(
string first,
string second,
int age)
{
public int Age = age;
public string FullInfo => $"{first} {second}";
}То мы имеем 3 поля (1 – int и 2 – ссылки на string) для неявных полей, всего 32*2 + 4 = 70 байта на экземпляр (если строки пустые). Теперь, если мы всего лишь поменяем первичный конструктор:
public struct Human(
string name,
int age)
{
public int Age = age;
public string FullInfo => name;
}Внезапно вместо 3 полей останется 2 и размер станет 32 + 4 = 36 байт, но это без явного изменения публичных полей структуры (если вы не знаете, как реализованы первичные конструкторы). Это может иметь очень плохие последствия, если мы полагаемся на размер Human где-то в коде!
5. Путаница с записями
Всё-таки стоит помнить, что, несмотря на почти идентичный синтаксис, следующие объявления не взаимозаменяемы:
public record Human(string FirstName, string SecondName);
public class Human(string FirstName, string SecondName);
Первичные конструкторы не создают публичных полей нигде, кроме типов записей. Поэтому, если вы вдруг решите сменить запись на класс, то ваш код внезапно перестанет компилироваться.
Вывод очень простой: используйте новые конструкции языка с умом, не перегружайте логику, тогда код будет наиболее читабелен, а приложение будет функционировать с ожидаемым результатом.
Источник:
1) https://andrewlock.net/thoughts-about-primary-constructors-3-pros-and-5-cons/