- PVSM.RU - https://www.pvsm.ru -
Привет!
На днях мне в очередной раз на глаза попал код вида
if(someParameter.Volatilities.IsEmpty())
{
// We have to report about the broken channels, however we could not differ it from just not started cold system.
// Therefore write this case into the logs and then in case of emergency IT Ops will able to gather the target line
Log.Info("Channel {0} is broken or was not started yet", someParameter.Key)
}
В коде есть одна довольно важная особенность: получателю крайне хотелось бы знать, что произошло на самом деле. Ведь в одном случае у нас проблемы с работой системой, а в другом — мы просто прогреваемся. Однако модель не дает нам этого (в угоду отправителю, который зачастую является автором модели).
Более того, даже факт "возможно, что-то не так" происходит из того, что коллекция Volatilities
пуста. Что в некоторых случаях может быть и корректно.
Я уверен, что большинство опытных разработчиков встречало в коде строки, в которых заключалось тайное знание в стиле "если выставлена эта комбинация флажков, то от нас просят сделать A, B и C" (хотя по самой модели этого не видно).
С моей точки зрения, подобная экономия на структуре классов сказывается крайне негативно на проекте в будущем, превращая его в набор хаков и костылей, постепенно трансформируя более-менее удобный код в legacy.
Важно: в статье я привожу примеры, которые полезны для проектов, в которых несколько разработчиков (а не один), плюс которые будут обновляться и расширяться в течении хотя бы 5-10 лет. Всё это не имеет смысла, если в проекте один разработчик лет на пять, или же если после релиза никаких изменений не планируется. И логично, если проект необходим всего на пару месяцев, нет смысла вкладываться в четкую модель данных.
Однако если же вы занимаетесь долгоиграющим — добро пожаловать под кат.
Зачастую одно и то же поле содержит объект, который может иметь разные смысловые значения (как в примере). Однако для экономии классов разработчик оставляет только один тип, снабжая его флагами (или комментариями в стиле "если тут ничего нет, значит ничего не подсчиталось"). Подобный подход может замаскировать ошибку (что плохо для проекта, однако удобно для команды, которая поставляет сервис, ведь баги-то не видны снаружи). Более корректный вариант, который позволяет даже на дальнем конце провода узнать, что происходит на самом деле — это использовать интерфейс + визиторы.
В этом случае пример из заголовка превращаяется в код вида:
class Response
{
public IVolatilityResponse Data { get; }
}
interface IVolatilityResponse
{
TOutput Visit<TInput, TOutput>(IVolatilityResponseVisitor<TInput, TOutput> visitor, TInput input)
}
class VolatilityValues : IVolatilityResponse
{
public Surface Data;
TOutput Visit<TInput, TOutput>(IVolatilityResponseVisitor<TInput, TOutput> visitor, TInput input)
=> visitor.Visit(this, input);
}
class CalculationIsBroken : IVolatilityResponse
{
TOutput Visit<TInput, TOutput>(IVolatilityResponseVisitor<TInput, TOutput> visitor, TInput input)
=> visitor.Visit(this, input);
}
interface IVolatilityResponseVisitor<TInput, TOutput>
{
TOutput Visit(VolatilityValues instance, TInput input);
TOutput Visit(CalculationIsBroken instance, TInput input);
}
При подобного рода обработке:
Response
в json
/protobuf
, так как там теряется информацию о типе. Нам придется создавать специальный контейнер, который будет это делать (например, можно сделать класс, который содержит по отдельному полю для каждой реализации, однако только одно из них будет заполнено).IVolatilityResponseVisitor<TInput, TOutput>
, а значит компилятор заставит поддержать его в коде. Программист не забудет обработать новый тип, иначе ведь проект не скомпилируется.В ряде других языков (например, Scala
или Kotlin
) есть ключевые слова, которые позволяют запретить наследоваться от определенного типа, при определенных условиях. Таким образом мы на этапе компиляции знаем всех возможных наследников нашего типа.
В частности, пример выше можно переписать на Kotlin
так:
class Response (
val data: IVolatilityResponse
)
sealed class VolatilityResponse
class VolatilityValues : VolatilityResponse()
{
val data: Surface
}
class CalculationIsBroken : VolatilityResponse()
Получилось чуть меньше кода, однако теперь мы в процессе компиляции знаем, что все возможные наследники VolatilityResponse
находятся в одном с ним файле, а значит следующий код не скомпилируется, так как мы не перебрали все возможные значения класса.
fun getResponseString(response: VolatilityResponse) = when(response) {
is VolatilityValues -> data.toString()
}
Однако стоит помнить, что подобные проверки работают только для функциональных вызовов. Код ниже соберется без ошибок:
fun getResponseString(response: VolatilityResponse) {
when(response) {
is VolatilityValues -> println(data.toString())
}
}
Рассмотрим относительно типичную разработку для базы данных. Скорее всего, где-то в коде у вас будут идентификаторы объектов. Например:
class Group
{
public int Id { get; }
public string Name { get; }
}
class User
{
public int Id { get; }
public int GroupId { get; }
public string Name { get; }
}
Вроде стандартный код. Типы даже совпадают с аналогичными в базе данных. Однако вопрос: корректен ли код ниже?
public bool IsInGroup(User user, Group group)
{
return user.Id == group.Id;
}
public User CreateUser(string name, Group group)
{
return new User
{
Id = group.Id,
GroupId = group.Id,
name = name
}
}
Ответ — скорее всего нет, так как мы сравниваем Id
пользователя и Id
группы в первом примере. А во втором ошибочно ставим id
из Group
как id
от User
.
Как ни странно, это довольно просто исправить: просто заведите типы GroupId
, UserId
и так далее. Таким образом создание User
работать уже не будет, так как у вас не сойдутся типы. Что невероятно классно, ведь вы смогли рассказать о модели компилятору.
Более того, у вас будут правильно работать методы с однотипными параметрами, так как теперь они не будут повторяться:
public void SetUserGroup(UserId userId, GroupId groupId)
{
/* some sql code */
}
Однако вернемся к примеру со сравнением идентификаторов. Он чуть сложнее, так как надо запретить компилятору сравнивать несравнимое в процессе сборки.
И сделать это можно следующим образом:
class GroupId
{
public int Id { get; }
public bool Equals(GroupId groupId) => Id == groupId?.Id;
[Obsolete("GroupId can be equal only with GroupId", error: true)]
public override bool Equals(object obj) => Equals(obj as GroupId)
public static bool operator==(GroupId id1, GroupId id2)
{
if(ReferenceEquals(id1, id2))
return true;
if(ReferenceEquals(id1, null) || ReferenceEquals(id2, null))
return false;
return id1.Id == id2.Id;
}
[Obsolete("GroupId can be equal only with GroupId", error: true)]
public static bool operator==(object _, GroupId __) => throw new NotSupportedException("GroupId can be equal only with GroupId")
[Obsolete("GroupId can be equal only with GroupId", error: true)]
public static bool operator==(GroupId _, object __) => throw new NotSupportedException("GroupId can be equal only with GroupId")
}
Как результат:
GroupId
запихнуть поле UserId
IEquitable
, надо также реализовать метод GetHashCode
), так что пример не надо просто копировать в проект. Однако сама идея ясна: мы четко запретили компилятору выражения, когда сравниваются не те типы. Т.е. вместо выражения "а равны ли эти фрукты?" компилятор теперь видит "а равна ли груша яблоку?".Зачастую в наших приложениях для типов вводятся дополнительные правила, которые легко проверить. В худшем случае ряд функций выглядит примерно так:
void SetName(string name)
{
if(name == null || name.IsEmpty() || !name[0].IsLetter || !name[0].IsCapital || name.Length > MAX_NAME_COLUMN_LENGTH)
{
throw ....
}
/**/
}
То есть функция принимает на вход довольно широкий тип, а далее запускает провеки. В общем случае это неправильно, так как:
string
, которая будет обозначать name
, мы не упали сразу, а зачем-то продолжили выполнение, чтобы упасть на несколько инструкций процессора позже.Корректное поведение:
Name
).string
в Name
, чтобы как можно быстрее получить ошибку.Как результат мы получаем:
name
мы вынесли в конструктор.void UpdateData(Name name, Email email, PhoneNumber number)
. Ведь теперь мы передаем не три одинаковых string
, а три разных разных сущности.Вводя довольно строгую типизацию, нельзя также забывать о том, что при передаче данных в Sql, нам все-таки надо получить настоящий идентификатор. И в этом случае логично немного обновить типы, оборачивающие один string
:
interface IValueGet<TValue>{ TValue Wrapped { get; } }
. В этом случае в слое трансляции в Sql мы сможем получить значение напрямуюinterface IValueGet<TValue>
{
TValue Wrapped { get; }
}
abstract class BaseWrapper : IValueGet<TValue>
{
protected BaseWrapper(TValue initialValue)
{
Wrapped = initialValue;
}
public TValue Wrapped { get; private set; }
}
sealed class Name : BaseWrapper<string>
{
public Name(string value)
:base(value)
{
/*no necessary validations*/
}
}
sealed class UserId : BaseWrapper<int>
{
public UserId(int id)
:base(id)
{
/*no necessary validations*/
}
}
Говоря о создании большого числа типов, можно часто встретить два диалектических аргумента:
Строго говоря, оба аргумента зачастую приводят без фактов, однако:
int
, string
и другие примитивные типы. Основная причина — для этого необходимо сначала сделать типовой жирный проект для теста, а потом обосновать, что именно этот проект является типовым. И вот со вторым пунктом всё сложно, так как в рельной жизни проекты действительно разные. Однако делать синтетические тесты будет довольно странно, ибо, как я уже говорил, на создание объектов микротипов в Enterprise приложениях, по моим замерам, всегда уходило пренебрежительно мало ресурсов (на уровне погрешности измерений).Важно: подобными оптимизациями следует заниматься только тогда, когда вы получили гарантированные факты, что именно микротипы тормозят приложение. По моему опыту, подобная ситуация скорее невозможна. С большей долей вероятности у вас будет тормозить тот же логгер [1], потому что каждая операция ждет сброса на диск (причем всё было приемлемо на компьютере разработчика с M.2 SSD, однако пользователь со старым HDD видит совсем другие результаты).
Однако, сами приемы:
struct
в .Net может вызывать частый boxing/unboxing. А заодно подобные структуры могут потребовать больше памяти в коллекциях Dictionary
/Map
(так как в них массивы выделяются с запасом).inline
типы из Kotlin/Scala имеют ограниченную применимость. Например, в них нельзя хранить несколько полей (что может быть иногда полезно для кеширования значения ToString
/GetHashCode
).Equals
/GetHashCode
. Однако она же ускоряет будущие сравнения объектов, если вы прокешируете значение хеша, так как в этом случае, если они отличаются, то и объекты разные. А одинаковые объекты зачастую будут иметь одну ссылку.GetHashCode
/Equals
(см. пункт выше). Плюс время жизни новых объектов (которые однако есть в кеше) радикально упадет, так что они будут попадать разве что в Generation 0.ToUpperInvariant
от аргумента, или в цикле проверить, что все буквы большие. В первом случае гарантировано будет создана новая строка, во втором же — максимум итератор. В итоге вы экономите на памяти (однако в обоих случаях каждый символ всё равно будет проверен, так что производительность вырастет только в контексте более редкой сборки мусора).Еще раз повторю важный пункт из заглавия: все вещи, описанные в статье, имеют смысл в больших проектах, которые разрабатываются и используются годами. В те, где осмысленно уменьшать стоимость поддержки и удешевлять добавление новой функциональности. В остальных случаях зачастую резоннее всего как можно быстрее сделать продукт, не заморачиваясь тестами, моделями и "хорошим кодом".
Однако именно для долгих проектов разумно использовать максимально строгую типизацию, где мы в модели сможем строго описать, какие значение в принципе возможны.
Если ваш сервис иногда может возвращать нерабочий результат, то выражайте это в модели и показывайте разработчику явно. Не добавляйте тысячу флажков с описаниями в документации.
Если ваши типы могут быть одинаковыми в программе, однако они разные по сути бизнеса, то и определяйте их именно как разные. Не стоит их смешивать, пусть даже типы их полей совпадают.
Если у вас есть вопросы к производительности — примените научный метод и сделайте тест (а лучше — попросите независимого человека проверить всё это). При таком сценарии вы на самом деле будете ускорять программу, а не просто потратите время коллектива. Однако так же верно и обратное: если есть подозрение, что ваша программа или библиотека тормозит, то сделайте тест. Не надо говорить, что всё хорошо, просто покажите это в цифрах.
Автор: Игорь Манушин
Источник [4]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/java/326397
Ссылки в тексте:
[1] будет тормозить тот же логгер: https://habr.com/en/post/456488/
[2] делает это для малых временных объектов: https://github.com/dotnet/coreclr/blob/master/Documentation/design-docs/object-stack-allocation.md
[3] GraalVM в Java умеет: https://medium.com/graalvm/under-the-hood-of-graalvm-jit-optimizations-d6e931394797
[4] Источник: https://habr.com/ru/post/462655/?utm_source=habrahabr&utm_medium=rss&utm_campaign=462655
Нажмите здесь для печати.