- 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 запихнуть поле UserIdIEquitable, надо также реализовать метод 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
Нажмите здесь для печати.