3 минуты
Конструируй правильно
Конструктор - это точка входа в любой объект. Это метод, который служит инициализатором вашего типа, проверяет инварианты, переводит объект в состояние пригодное для использования.
Каждый день мы пишем свои типы, они в дальнейшем будут использовать и наши коллеги. Но сталкивались ли вы с тем, что после создания объекта вы получали ошибки связанные с тем, что после создания объекта какие-то из полей не были проинициализированы? Если не встречали, то вам очень повезло, к сожалению, я не из таких людей.
Так почему же возникают такие проблемы, если конструктор является такой же важной частью типа, как и его методы, которым нужно уделять не меньшее внимания?
Давайте разбираться, для этого будем использовать такой тип:
public class Tree<T>
{
private Node? _root;
private IEqualityComparer<T> _comparer;
public Tree(IEnumerable<T> items)
{
// ...
}
public Tree(IEnumerable<T> items, IEqualityComparer<T> comparer)
{
// ...
}
public Tree(IEqualityComparer<T> comparer)
{
// ...
}
private void AddRange(IEnumerable<T> items)
{
// ...
}
private class Node
{
// ...
}
}
Распространенная реализация
public Tree(IEnumerable<T> items)
{
_comparer = EqualityComparer<T>.comparer;
AddRange(items);
}
public Tree(IEnumerable<T> items, IEqualityComparer<T> comparer)
{
_comparer = comparer;
AddRange(items);
}
public Tree(IEqualityComparer<T> comparer)
{
_comparer = comparer ?? throw new ArgumentNullException(nameof(comparer));
}
В примере выше есть ошибка, заметили ли вы её?
Давайте изучим код ещё раз, подумаем и БИНГО! В конструкторе 2, мы забыли сделать проверку на null. Получается, что при вызове этого конструктора и передаче туда null для comparer, нормальной ошибки от класса мы не получим и упадём в AddRange.
Минусы
- В каждом конструкторе мы дублируем логику инициализации.
- Неинициализированные поля.
- Поменяли логику в одном конструкторе и оставили остальные без изменений.
- Разбухание кода, что влечет к его усложнению и затруднению в поддержке.
Первичный конструктор
В типе всегда должен быть главный конструктор и остальные конструкторы в конечном счете всегда должны вызывать именно его. Тогда у нас будет единая точка, для инициализации типа, что помогает решить огромный пласт ошибок.
Давайте исправим наш пример:
public Tree(IEnumerable<T> items)
: this(items, EqualityComparer<T>.Default)
{
}
public Tree(IEnumerable<T> items, IEqualityComparer<T> comparer)
: this(comparer)
{
AddRange(items);
}
public Tree(IEqualityComparer<T> comparer)
{
_comparer = comparer ?? throw new ArgumentNullException(nameof(comparer));
}
У нас есть главный конструктор Tree(IEqualityComparer comparer), который вызывают все остальные в конечном счете.
Мы знаем, что нашему типу обязательно нужен IEqualityComparer, давайте его требовать в главном конструкторе. Там мы можем добавить проверку на null и мы точно будем знать, что там есть все нужные проверки.
Обратите внимание, что конструктор 1, вызывает конструктор 2, который в конечном счете вызывает главный конструктор (номер 3).
А что сейчас?
С появлением record. Данную идею включили в язык, чему я безумно рад. Но не каждый может использовать необходимую версию языка и не всегда нужно использовать record, для его компилятор требует вызова primary constructor.
public record StreamAsString(Stream Stream)
{
// без this(File.OpenRead(path)), не компилируется
public StreamAsString(string path) : this(File.OpenRead(path))
{
}
public string Content
{
get
{
using var reader = new StreamReader(Stream);
return reader.ReadToEnd();
}
}
}
Итог
Переиспользуя готовые конструкторы, мы перекладываем ответственность проверки на них и не будем задумываться о том, какие поля нам нужно инициализировать или как это правильно сделать.
В конечном счете это кто-то должен будет реализовать, но это будет единая точка, что очень упрощает понимание и поддержание такого кода.