Я устал от одержимости примитивами и от чрезмерного использования примитивных типов для моделирования функциональной области.
Значение в
string
не лучший тип для записи адреса электронной почты или страны проживания пользователя. Эти значения заслуживают гораздо более богатых и специализированных типов. Мне нужно, чтобы существовал тип данных EmailAddress
, который не может быть null. Мне нужна единая точка входа для создания нового объекта этого типа. Он должен валидироваться и нормализироваться перед возвратом нового значения. Мне нужно, чтобы этот тип данных имел полезные методы наподобие .Domain()
или .NonAliasValue()
, которые бы возвращали для введённого foo+bar@gmail.com
значения gmail.com
и foo@gmail.com
. Эта полезная функциональность должна быть встроена в эти типы. Это обеспечивает безопасность, помогает предотвращать баги и существенно повышает удобство поддержки.Тщательно спроектированные типы с полезной функциональностью мотивируют программиста поступать правильно.
Например,
EmailAddress
может иметь два метода проверки равенства:-
Equals
возвращал быtrue
, если два адреса электронной почты (нормализованных) идентичны. -
EqualsInPrinciple
также возвращал быtrue
дляfoo@gmail.com
иfoo+bar@gmail.com
.
Такие специфические для типов методы были бы чрезвычайно полезны во множестве случаев. Пользователю нельзя отказывать в логине, если он зарегистрировался с почтой
jane@gmail.com
, а пытается войти с Jane@gmail.com
. К тому же будет очень удобно сопоставлять пользователя, связавшегося со службой поддержки с основного адреса почты (foo@gmail.com
), и зарегистрированный аккаунт (foo+svc@gmail.com
). Это типичные требования, которые простой string
не может выполнить без кучи дополнительной логики, разбросанной по кодовой базе.Примечание: согласно официальному RFC, часть адреса электронной почты до символа @ может быть чувствительной к регистру, однако все популярные хосты работают с ней как с нечувствительной к регистру, поэтому логично будет учитывать это знание в типе domain.
Хорошие типы помогают предотвращать баги
В идеале мне бы хотелось пойти ещё дальше. Адрес почты может быть верифицированным и неверифицированным. Обычно адрес электронной почты валидируется отправкой во входящие уникального кода. Такие «деловые» взаимодействия тоже можно выразить через систему типов. Пусть у нас будет второй тип с именем
VerifiedEmailAddress
. Если хотите, он даже может наследовать от EmailAddress
. Меня это не волнует, но убедитесь, что получить новый экземпляр VerifiedEmailAddress
можно только в одном месте кода, а именно у сервиса, отвечающего за валидацию адреса пользователя. И внезапно оказывается, что остальная часть приложения может использовать этот новый тип для предотвращения багов.Любая функция отправки электронных писем может положиться на безопасность
VerifiedEmailAddress
. Представьте, что было бы, если бы адрес электронной почты был записан как простой string
. Разработчику приходилось бы сначала находить/загружать соответствующий аккаунт пользователя, искать какой-нибудь непонятный флаг наподобие HasVerifiedEmail
или IsActive
(который, кстати, является наихудшим флагом, поскольку со временем его значимость становится всё более существенной), после чего надеяться, что флаг установлен правильно, а не инициализирован ошибочно как true
в каком-то стандартном конструкторе. В такой системе слишком много возможностей для ошибки! Использование примитивного string
для объекта, который легко выразить в виде собственного типа — это ленивое и лишённое воображения программирование.Расширенные типы защищают от будущих ошибок
Ещё один замечательный пример — деньги! Просто куча приложений выражает денежные значения при помощи типа
decimal
. Почему? У этого типа так много проблем, что такое решение мне кажется непостижимым. Где обозначение вида валюты? В любой сфере, где работают с деньгами людей, должен быть специализированный тип Money
. Он как минимум должен включать в себя вид валюты и перегрузки операторов (или другие меры защиты), чтобы предотвратить глупые ошибки наподобие умножения $100 на £20. Кроме того, не у всех валют хранится только два знака после запятой. У некоторых валют, например у бахрейнского или кувейтского динара, их три. Если вы имеете дело с инвестициями или кредитами в Чили, то фиксировать условную расчётную единицу нужно с четырьмя знаками после запятой. Этих аспектов уже достаточно для того, чтобы оправдать создание специального типа Money
, но и это ещё не всё.Если ваша компания не создаёт всё самостоятельно, то вам рано или поздно придётся работать со сторонними системами. Например, большинство платёжных шлюзов передаёт запросы и ответы по деньгам в виде значений
integer
. Значения integer не страдают от проблем с округлением, свойственных типам float
и double
, поэтому они предпочтительнее, чем числа с плавающей запятой. Единственная тонкость заключается в том, что значения нужно передавать в производных единицах (например, в центах, пенсах, дирхамах, грошах, копейках и так далее), то есть если ваша программа работает с значениями в decimal
, вам придётся постоянно преобразовывать их туда и обратно при общении с внешним API. Как говорилось ранее, не во всех валютах используется два знака после запятой, поэтому простым умножением/делением на 100 каждый раз не обойтись. Всё очень быстро может сильно усложниться. Ситуацию можно было бы существенно упростить, если бы эти правила были инкапсулированы в краткий единый тип:-
var x = Money.FromMinorUnit(100, "GBP")
: £1 -
var y = Money.FromUnit(100.50, "GBP")
: £1.50 -
Console.WriteLine(x.AsUnit())
: 1.5 -
Console.WriteLine(x.AsMinorUnit())
: 150
Усугубляет ситуацию то, что во многих странах форматы обозначения денег тоже отличаются. В Великобритании десять тысяч фунтов пятьдесят пенсов можно записать как
10,000.50
, однако в Германии десять тысяч евро и пятьдесят центов будут записываться как 10.000,50
. Представьте объём кода, связанного с деньгами и валютами, который будет разбросан (а возможно и дублирован с небольшими расхождениями) по кодовой базе, если эти правила не будут помещены в один тип Money
.Кроме того, в специализированный тип
Money
можно включить множество других фич, сильно упрощающих работу с денежными значениями:var gbp = Currency.Parse("GBP");
var loc = Locale.Parse("Europe/London");
var money = Money.FromMinorUnit(1000050, gbp);
money.Format(loc) // ==> £10,000.50
money.FormatVerbose(loc) // ==> GBP 10,000.50
money.FormatShort(loc) // ==> £10k
Разумеется, для моделирования такого типа
Money
придётся приложить усилия, но после его реализации и тестирования остальная часть кодовой базы будет в гораздо большей безопасности. К тому же это предотвратит большинство багов, которые в противном случае со временем будут проникать в код. Хотя такие мелкие аспекты, как защищённая инициализация объекта Money
через Money.FromUnit(decimal v, Currency c)
или Money.FromMinorUnit(int v, Currency c)
, могут показаться незначительными, они заставляют последовательных разработчиков задумываться, каким из них является значение, полученное от пользователя или внешнего API, а значит, предотвращать баги с самого начала.Продуманные типы могут предотвращать нежелательные побочные эффекты
Расширенные типы хороши тем, что им можно придавать любую форму. Если моя статья пока не пробудила ваше воображение, позвольте продемонстрировать ещё один хороший пример того, как специализированный тип может спасти команду разработчиков от излишней траты ресурсов и даже багов, угрожающих безопасности.
В каждой кодовой базе, с которой я работал, в виде параметра функции было что-то наподобие
string secretKey
или string password
. Что может пойти не так с этими переменными?Представьте такой код:
try
{
var userLogin = new UserLogin
{
Username = username
Password = password
}
var success = _loginService.TryAuthenticate(userLogin);
if (success)
RedirectToHomeScreen(userLogin);
ReturnUnauthorized();
}
catch (Exception ex)
{
Logger.LogError(ex, "User login failed for {login}", userLogin);
}
Здесь возникает следующая проблема: если в процессе аутентификации срабатывает исключение, то это приложение запишет в логи (случайно) пароль пользователя в текстовом виде. Разумеется, подобный код вообще не должен существовать, и можно надеяться, что вы отловите его во время код-ревью до попадания в продакшен, но в реальности такое время от времени случается. Вероятность большинства таких багов со временем растёт.
Изначально класс
UserLogin
мог иметь другой набор свойств и при первоначальном код-ревью этот фрагмент кода, вероятно, был хорошим. Годами позже кто-то мог изменить класс UserLogin
так, что в нём появился пароль в текстовом виде. Затем эта функция даже не появилась в diff, который был отправлен для ещё одного ревью, и вуаля, вы только что добавили в код баг, угрожающий безопасности. Я уверен, что каждый разработчик, имеющий несколько лет опыта, рано или поздно сталкивался с подобными проблемами.Однако этого бага можно было бы легко избежать добавлением специализированного типа.
В языке C# (который я возьму для примера) при записи объекта в лог (или куда-то ещё) автоматически вызывается метод
.ToString()
. Зная это, можно спроектировать такой тип Password
:public readonly record struct Password()
{
// Здесь будет реализация
public override string ToString()
{
return "****";
}
public string Cleartext()
{
return _cleartext;
}
}
Это мелкое изменение, но теперь случайно вывести куда-то пароль в текстовом виде оказывается невозможно. Разве это не здорово?
Разумеется, в процессе аутентификации вам всё равно может понадобиться значение в виде простого текста, однако доступ к нему можно получить при помощи метода с очень понятным именем
Cleartext()
. Уязвимость этой операции становится сразу очевидной, и это автоматически мотивирует разработчика использовать этот метод по назначению и обращаться с ним аккуратно.Тот же принцип применим и при работе с личными данными (например, номером паспорта, индивидуальным налоговым номером и так далее). Смоделируйте эту информацию при помощи специализированных типов. Переопределите стандартные функции наподобие
.ToString()
под себя и раскрывайте уязвимые данные через функции с соответствующими именами. Так личные данные никогда не утекут в логи и другие места, удаление из которых потребует много труда.Такие мелкие хитрости могут оказать большую пользу!
Превратите это в привычку
Каждый раз, когда вы имеете дело с данными, связанными с определёнными правилами, поведением или опасностями, подумайте, как вы можете помочь себе созданием явно заданного типа.
Взяв за основу пример с типом
Password
, можно снова расширить его!Перед сохранением в базу данных пароли хэшируются, так ведь? Разумеется, однако хэш — это не просто
string
. В процессе логина нам придётся сравнивать ранее сохранённый хэш с новым вычисленным хэшем. Проблема в том, что не каждый разработчик является специалистом по безопасности, а потому он может и не знать, что сравнение двух строк хэшей может сделать код уязвимым к атакам по времени.Рекомендуется проверять равенство хэшей двух паролей неоптимизированным образом:
// Сравнение двух байтовых массивов на равенство.
// Метод специально написан так, чтобы цикл не оптимизировался.
[MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)]
private static bool ByteArraysEqual(byte[] a, byte[] b)
{
if (a == null && b == null)
{
return true;
}
if (a == null || b == null || a.Length != b.Length)
{
return false;
}
var areSame = true;
for (var i = 0; i < a.Length; i++)
{
areSame &= (a[i] == b[i]);
}
return areSame;
}
Примечание: пример кода взят из репозитория ASP.NET Core.
Поэтому будет совершенно логично закодировать эту функциональность в специализированный тип:
public readonly record struct PasswordHash
{
// Здесь будет реализация
public override bool Equals(PasswordHash other)
{
return ByteArraysEqual(this.Bytes(), other.Bytes());
}
}
Если
PasswordHasher
возвращает значения только типа PasswordHash
, тогда даже не разбирающиеся в безопасности разработчики будут вынуждены использовать безопасный способ проверки на равенство.Тщательно продумывайте способ моделирования функциональной области!
Не нужно говорить, что в программировании нет чётких правильных или ошибочных решений, и всегда существуют нюансы, которые нельзя раскрыть в одном посте, однако в целом я рекомендую подумать о том, как сделать систему типов вашим лучшим другом.
Во многих современных языках программирования есть очень богатые системы типов, и я считаю, что мы сильно недооцениваем их как способ совершенствования своего кода.
Комментарии (76)
n0wheremany
08.11.2022 09:50+1Сейчас (2022) эту статью можно лишь включить в обучение, почему до этого дошли, а тогда, когда она была написана (в 2011 на на пост 2007), это возможно было обсуждаемым, но не сегодня.
F0iL
08.11.2022 10:37+12Оригинал перевода "Published 01 Nov 2022".
Более того, сейчас такая статья даже актуальнее, чем могла бы быть в 2007.
volchenkodmitriy
08.11.2022 09:55+2Контроль ошибок ввода и обработки - единственный способ уменьшить количество неверных данных до приемлемого уровня!
0xd34df00d
08.11.2022 17:24+11Типы — неплохой способ гарантировать, что вы проконтролировали ввод должным образом.
Fil
08.11.2022 09:59+19Пусть у нас будет второй тип с именем VerifiedEmailAddress. Если хотите, он даже может наследовать от EmailAddress
Понимаю, что это всего лишь пример, но тут классическая ловушка наследования. Мало того, что свойство Verified относится скорее к аккаунту, а не к адресу, так еще через пару итераций у нас будет что-то вроде:
-
VerifiedEmailAddress
VerifiedPersonalEmailAddress
VerifiedBusinessEmailAddress
-
NonVerifiedEmailAddress
NonVerifiedPersonalEmailAddress
NonVerifiedBusinessEmailAddress
А потом в эту иерархию понадобиться впихнуть IsActiveEmailAddress, IsSubscribedEmailAddress. Здесь, как мне кажется, более уместна композиция (псевдокод):
class Account: email : EmailAddress is_verified : bool, type : {Personal, Business}, is_active : bool, is_subscribed : bool
OldNileCrocodile
08.11.2022 10:20-1"Verified" - не свойство сущности, а состояние целого приложения. Причём в зависимости от типа проверяемой сущности у нас могут быть два различных состояния "VerifiedUserEmail" и "VerifiedUser". Тоже самое касается извлечения данных. Шаг извлечения - переход из одного состояния приложения в другое. Но поскольку добрая половина людей не умеют в теорию графов, а
computer science
звучит для них как что-то сложное и крутое из Гарварда/MIT/Америки, то имеем, что большинство проектов забиты сущностями, выполняющими одни и те же шаги - извлечение данных, но с разными сущностями. А сущностей в бизнес логике может быть несколько сотен.Fil
08.11.2022 11:17+7Не уверен, что правильно вас понял, но попробую ответить. Да, в теории очень здорово писать send_super_secret(email : VerifiedEmailAddress) и сама система типов будет гарантировать нам, что письма будут отправляться только верифицированным адресам. Во многих случаях это очень уместно и элегантно. Особенно если тип почты не нужно менять динамически. Но здесь мы начинаем подмешивать бизнес-логику. Например, нам может понадобиться разрешать отправку верифицированным адресам, но только тем, которые были активны в последний месяц лунного календаря. Завтра требования могут ослабнуть, и необходимо будет отправлять не только верифицированным, но и бывшим премиум-пользователям. Я к тому, что в send_super_secret вероятно добавятся различные условия и желательно предусмотреть возможность их появления заранее. (Возможно здесь помогут контракты, но я не силен в этом)
novoselov
08.11.2022 19:39+2Это проблема решается поддержкой контрактов в языке, примерно так
send_super_secret (email : EmailAddress) require email.isVerified { channel.send(email, message) }
А с введение интерфейсов Verifiable и поддержкой дженериков можно безопасно переиспользовать такой код
<T: Verifiable> send_super_secret (token : T) require token.isVerified { channel.send(token, message) }
0xd34df00d
08.11.2022 21:01+1Обычно контрактами называют рантайм-проверку свойств, разве нет?
Компилтайм-контракты — это зависимые типы.
Aleshonne
08.11.2022 21:45Контракты могут как статически проверяться, так и в рантайме. Хорошо контракты сделаны, например, в Аде: всё, что можно проверить на этапе компиляции, будет проверено там, для остального в зависимости от режима сборки могут быть созданы рантайм-проверки. Прувер там, правда, не слишком мощный, да и синтаксис уже устаревший, но язык в целом прикольный.
Например, там давно существует возможность создавать кучу числовых типов разных сортов, несовместимых между собой без явного приведения (которую в посте как раз рекламируют):
-- десятичное число из 12 цифр с фиксированной точкой type Money is delta 10**(-4) digits 12; -- целое число от 0 до 10 тысяч включительно -- переполнение выбросит исключение type Count is range 0..10000; -- беззнаковый байт, для него допустимо переполнение type Byte is mod 2**8; -- подтип типа Count -- может автоматически расширяться до родительского типа subtype SmallCount is Count range 0..100; -- стандартный тип с плавающей точкой type Float is digits 6 range -3.40282E+38..3.40282E+38
mayorovp
08.11.2022 21:45+1Контрактами называют подход, при котором функции имеют пред- и постусловия, выраженные в виде логических утверждений.
Как оно там реализовано: через скрытые завтипы, символьные вычисления, в рантайме или вообще как комментарии — вопрос исключительно реализации.
0xd34df00d
08.11.2022 22:05+3Это не только вопрос реализации, потому что в идеале хотелось бы знать о нарушении контрактов задолго до запуска программы.
dyadyaSerezha
08.11.2022 12:18+4Да нет, verified именно email, а не аккаунт. Аккаунт часто уже есть и верифицирован (по уникальному коду в смс, например), а теперь пользователь добавил email и надо верифицировать и его. В целом же согласен - громодить иерархию классов тут не самое лучшее решение
DeepFakescovery
09.11.2022 15:59-1очешуеть, надо было еще немного подробностей реализации заложить в имена типов.
-
Samhuawei
08.11.2022 10:03+4Проблема в том, что не каждый разработчик является специалистом по безопасности, а потому он может и не знать, что сравнение двух строк хэшей может сделать код уязвимым к атакам по времени.
Sleep(1000) сделает ваш код намного безопаснее в этом плане.
AMDmi3
08.11.2022 13:05+3Во-первых, добавление константы никак не повлияет на распределение задержки. Во-вторых sleep никак не помешает подбирать хэш параллельно и только положит сервер. Вы, наверное, имели в виду не допускать попыток логина чаще раза в секунду, но это не помешает набрать статистику задержек за обозримое время. С другой стороны, побайтово строки давно не сравнивают и вообще хэш - это вектор ui64. Но пример хороший.
Anvano
08.11.2022 14:05Почему даже случайная задержка при каждом выполнении не избавляет от атак по времени:
https://blog.ircmaxell.com/2014/11/its-all-about-time.html#A-Note-On-“Random-Delays”
Aleshonne
08.11.2022 17:18+3Равномерно распределённая случайная задержка не поможет, а вот что-то вроде распределения Паскаля (с редкими большими значениями и частыми маленькими), как мне кажется, сильно затруднит атаку по времени.
Правда, есть подозрение, что если человек в курсе про атаку по времени, то он просто реализует безопасное сравнение хэшей, а не будет извращаться с написанием какой-то странной математики.
Foror
08.11.2022 18:51>безопасное сравнение хэшей
Можно подробнее?Aleshonne
08.11.2022 20:08+1Например, с использованием такого тождества (a, b — хэши длиной L, a_i — i-ый байт соответствующего хэша):
Если длина хэша кратна 8, можно соптимизировать на операции с uint64, так что ещё и работать оно быстро будет.
Вот, например, реализация этого тождества: https://github.com/php/php-src/blob/master/main/safe_bcmp.c
Можно ещё считать количество совпадающих байт и сравнивать с длиной хэша или заниматься какой-нибудь лютой математикой (например, принять хэши за вектора и определить длину этих векторов и угол между ними), вариантов много.
MKMatriX
08.11.2022 10:38+1Все это хорошо, но только когда пользовательская база достаточно большая, чтобы тратить на это время. Все же написать что-то в стиле
"user@server.domain".split("@")[0] // хоть и сложно читается, но просто пишется "user@server.domain".Domain() // пишется не очевидно, откуда-то большие буквы, домен и поддомен объединены в общем нужно где-то искать доку
Дальше больше поводов для холивара)
Использование только примитивов - проще читать. Просто потому что заучить примитивы можно, а расширяемую систему типов нет. Ее можно либо знать если писал сам, либо каждый раз подглядывать, ибо в другой фирме уже будут другие типы.
Несмотря на улучшение безопасности, на деле одни уязвимости переходят в другие. Тут все просто, кода без багов не бывает, поэтому чем известней кодовая база, тем больше в базах уязвимостей записей о ней. По факту, работа с безопасностью перекладывается на разработчиков либ, и админов их обновляющих.
Инкапсуляция это зло. Очень прикольно, когда для валидации имейла у тебя есть очень мощная и страшная либа, с кучей регулярок, учета доменов в стиле IPv6 и т.д.. Однако, что-то всегда меняется, а что-то нет, и единственный способ проверить имейл по настоящему это отправить на него письмо. Это не очень очевидно, но представим что вы пытаетесь зарегать пользователя, js проверил имейл, и тот подошел, а вот бек как ни странно, на такой имейл отправлять не умеет, хотя это и правда существующий и валидный имейл. В общем валидация на беке отличалась от валидации на фронте, они обе инкапсулированы, поэтому никто не залез смотреть.
Еще про инкапсуляцию. Со сложными типами невозможно работать без доки. Где-то нужно иметь список всех методов, что они принимают и возвращают. И работа в стиле, пишешь код, альт-таб в браузер, снова в код - это не нян. Порой проще вместо доки использовать исходники. А поглядев на исходники, зачастую возникает желание вообще не использовать эту либу) Да и хорошие доки встречаются редко. В общем все круто, когда сделано идеально, однако в реальности так бывает только когда работают за идею, а сделанным за деньги, пользоваться можно тоже только за деньги.
Еще про инкапсуляцию. Часто в либах зачем-то используют private и protected, еще конечно константы и прочий сахар, мешающий все поломать. Это конечно прекрасно для разработки либы, однако для публики лучше все делать публичным XD это кажется очень плохой идеей по началу, однако будет меньше людей которые скопировали исходники или применяли хаки, чтобы сделать публичными один два метода, которые им зачем-то все же нужно перегрузить.
Типы не наводят порядок. Порядок должен быть в голове, если порядок в голову занесло вместе с типами, то это фальшивый порядок, пиритовое золото, он вроде как есть, только стоит без фундамента, поэтому рушится как только возникают проблемы.
Дальше уже не столь холиварные рассуждения.
Для разного размера компаний актуальны разные правила. Для маленьких можно даже от код-ревью отказаться, а для больших наоборот заставлять всех писать этими типами.
За все нужно платить. Готовый код - менее гибкий, нужно изучать, содержит баги.
За все нужно платить. Не готовый код - менее универсальный, нужно писать, содержит баги.
Далеко не везде нужно учитывать разницу в написании количества денег в Германии и Великобритании.
Очевидных названий методов, классов, констант - не бывает. (Всегда найдутся люди которым не понятно)
Очевидного поведения, не требующего документации - не бывает.
Хорошей понятной документации - не бывает.
А теперь рассуждения о том когда все же пора.
Если 0.1% ваших пользователей генерируют больше вашей зарплаты, то пора писать код и под них.
Если у вас очень много кода, то пора его рефакторить, и запихивать в классы или что там в вашем языке.
Если всем в вашей компании привычно, то можно и не то что в языке, но тогда вы привязаны к команде, а команда к компании.
Если другим разработчикам не привычно, то вас ждет вечный холивар, т.е. у вас есть небольшие шансы, что каждый конкретный разработчик скажет "да так лучше, вы были правы, а я нет", но от команды в целом вы этого никогда не услышите.
А если все же услышали, то в вашей компании есть более важные проблемы, чем плохой код.
dyadyaSerezha
08.11.2022 12:28+8Использование только примитивов - проще читать.
Проще, если это в одном месте. Если же мест много, а их в больших проектах очень много, то легче и правильнее написать один раз класс или функцию и дальше использовать везде. Это же азы.
Andrey_Solomatin
08.11.2022 12:38+20У вас приоритет на то чтобы код было легко писать или читать?
> Инкапсуляция это зло
Пока не захочется что-то поменять в большой кодовой базе.
> Со сложными типами невозможно работать без доки
А со сложными типами которые спрятанны внутри примитивов можно без доки?
А еще можно делать простые типы. В примере с деньгами методов намного меньше, чем у Decimal.
Имена и сигнатуры методов это тоже доки. Автодополнение и переход к исходному коду не занимают много времени.
> Порядок должен быть в голове
Я работал в больших проектах. Чтобы понять какая логика скрыта за базовыми типами приходится тратить время. У класса обёртки перед этим есть огромное приимущество, можно догадаться по API. И это логичное место для документации.
> В общем валидация на беке отличалась от валидации на фронте, они обе инкапсулированы
Тут не в инкапсуляции проблема.
> Если у вас очень много кода, то пора его рефакторить, и запихивать в классы или что там в вашем языке.
Тут такой момент. От рефакторинга вы не выиграете в безопастности, всё давно уже оттестированно в том чиле пользователями. И рефакторинг будет в разы дороже, чем елси бы делать внятную систему типов под доменную модель. Это надо делать сразу это не дорого. На тестировании быстро отобьётся.
Есть еше бонус по тестированию.email.Domain()
От этого кода можно ожидать что емейл будет провалидирован и метод уже оттестирован. Тестируется юниттестами."user@server.domain".split("@")[0]
Кстати домен будет user. Если будут две собаки, то будет неверное значение без ошибки.
Этот код с большой вероятностью будет в обработчике запроса и протестировать это можно будет только через интеграционные тесты.MKMatriX
09.11.2022 10:51Да у меня просто накипело, и на очередной депрессивный приступ, вызванный изменениями внешнего мира, наложилось.
А если чуть более серьезно. То видел много примеров, где ооп фанатизм очень вредил.
Код битры, в которой приходится работать. Это сразу чтобы словить все помидоры. В битре есть компоненты, они не так давно стали переходить на классы. Основной рекомендованный путь модификации стандартных компонентов, это пара файликов исполняющихся после них. У этого подхода есть проблемы с ajax, ибо компоненты в битре это нифига не контроллеры, хотя последнее время и пытаются таковыми стать. В общем с переходом компонентов из тупо кода в классы открыло новый путь расширения, а именно унаследовать оригинальный класс, перегрузить один из методов и все счастливы. Вот только деление на публичные и приватные методы было сделано, не лучшим образом. Настолько не лучшим, что лучше бы все сделали публичным, я серьезно. А еще лучше если бы доступ к методам указывал на вероятность их поломки обновлением битры.
Как-то опять таки на битре захотели построить нормальную систему. Федерального уровня. В общем одни классы упаковывали в другие, нифига не выносили код в абстрактные классы и т.д. Про именование методов я вообще молчу. Оно было в стиле
$Users = generator["Users"]; // тут было правило, что название переменной должно быть именем сущности $list = $Users->GetUsersListByIds($ids, [$select, $filter, $order, $limitOrPage, $whatever]); /* зачем-то есть название сущности в названии метода, порядок других полей - случайный, даже когда написал абстрактный GetListByIds все равно делали методы GetЧто-тоListByIds */
В общем там было очень много фигни, которая пыталась походить на что-то нормальное, а в результате отнимало время. Ни читать ни писать в таком стиле не хотелось почти никому, поэтому проект на полгода для одного человека, затянулся на два года для команды из менее десятка людей.
3. Самый спорный пример, ваша позиция скорее выдаст степень подсказок вашей ide. Иногда просто хешмапы оборачивают в классы. Все по красоте, геттеры-сеттеры для каждого каждого свойства, легко расширять, дополнять методами если вдруг нужно. Все очень правильно, ибо с такими классами не придется переписывать код, если вдруг понадобится их расширить. Хешмапы для этого явно не подходят. Казалось бы не на что ругаться. Вот только, эти классы никогда не потребуются как классы. С ними даже проще работать как с ассоциативными массивами (опять таки php), у массива легко посмотреть список ключей и значений, пройтись по ним всем и это все что нужно для этой сущности. Конечно в какие-то языки и редакторы встроены очень хорошие инструменты для работы с классами. Они подскажут название методов и кому-то по этим названиям легко ориентироваться, вот только для массивов уже есть куча готовых методов. И это я не к тому, что мол переписывайте классы на массивы, это я к тому, что класс не всегда лучше. Он очень часто лучше, но не всегда и не везде.Andrey_Solomatin
09.11.2022 11:59+3То видел много примеров, где ооп фанатизм очень вредил
Я тоже видел много отвратительного кода. В статье как раз расписаны примеры когда классы уместны. С фанатизмом нужно бороться образованием, а не отказом.
В общем одни классы упаковывали в другие, нифига не выносили код в абстрактные классы и т.д.
Кроме слова классы ничего общего с тем, про что статья нет. С тестами и классами вечно проблема, что их используют неправильно и это больно. Вместо того, чтобы понять как правильно делать от них отказываются.
Иногда просто хешмапы оборачивают в классы.
Если копнуть в классы Питона, то это и есть хэшмапа с доступом к аттрибутам через точку.
Вот только, эти классы никогда не потребуются как классы.
Так это же не для рантайма, это для человека который читает этот код и для инструментов по анализу кода. К классу можно добавить документацию, которая будет находится в очевидном месте. Класс можно тестировать в изоляции.
michael_v89
09.11.2022 14:39То видел много примеров, где ооп фанатизм очень вредил.
Код битры, в которой приходится работать.Код 1С-Битрикс это последнее, что надо использовать как пример применения ООП.
MKMatriX
09.11.2022 15:00Тут не спорю, за годы обратной совместимости он так и не смог перейти в нормально стандартизированную систему. Т.е. старые методы разные, но про них есть документация (не на все), да и комментарии, ответы на разных источниках. Новое ядро лучше, но на него нету документации, примеры только на внешних источниках и т.д.. Более того в старом ядре много не хорошего, типа запросов в цикле, всяких костылей и прочего. Что еще веселее новое ядро крайне ограничено в работе с инфоблоками и пользователями, т.е. двумя основными сущностями. Молчу о багах и бесконечных циклах в нем. Плюс отсутствие нормальной работы с массивом сущностей. Тут полностью согласен, это не нормальная система и явный пример говнокода.
Однако не так плоха битра как разработчики ее использующие. Там почти постоянно такой треш, что не знаешь не то смеяться, не то плакать. И тебе запросы в шаблонах, и использование чистого sql при чем конечно с возможностью инъекций, да и куча другой фигни.
Однако и плюсы у нее есть, с ней можно развернуть магазин вообще без навыков программирования. При чем большой и сложный. Плюс знакомая одинаковая админка, куча роликов про то, как в ней что сделать. Также вся интеграция с 1с, по хорошему должна быть со стороны 1с (жаль готовый модуль умеет мало). В общем каждый инструмент полезен для своих целей.
morijndael
09.11.2022 00:05+4Использование только примитивов - проще читать.
Искренне желаю вам не столкнуться с доказывающим обратное кодом
MKMatriX
09.11.2022 10:55Боюсь я не встречусь из-за легкого окр, говорящего мне что, пока я не спустился до примитивов я еще не прочитал. Т.е. с тем же .Domain() это не был бы метод получающий домен как в описании, а мне пришлось бы и правда хотя бы его алгоритм прочитать, или что еще хуже его реализацию.
В целом чтобы увидеть код моими глазами, представьте что вы сначала меняете все названия переменных и методов на случайные. Просто чтобы они вам не мешали, намекая на то как это должно работать. Тогда открывается возможность понять что код делает на самом деле, а не что хотел написать автор.
eonae
09.11.2022 14:17Очень много крайне спорных (хотяи интересных) утверждений. Но за поинт про фальшивый порядок - аплодирую стоя! Как же это верно...
yukon39
08.11.2022 11:34+8Кроме того, не у всех валют хранится только два знака после запятой. У некоторых валют, например, у бахрейнского или кувейтского динара, их три.
Странный тезис, количество "копеек" в той или иной валюте, весьма опосредованно связано с количеством знаков которые нужно хранить. Например, официальный курс ЦБ (а они-то уж точно знают, сколько копеек в рубле) доллара на сегодня составляет 60,9013 рубля.
Значения integer не страдают от проблем с округлением, свойственных типам
float
иdouble
, поэтому они предпочтительнее, чем числа с плавающей запятой.Имхо, те кто использует типы с плавающей запятой для денежных значений, вообще не представляют себе, что означает слово "плавающей", и чем они отличаются от типов с фиксированной запятой, которые как раз в первую очередь для денежных значений и создавались.
var x = Money.FromMinorUnit(100, "GBP")
: £1var y = Money.FromUnit(100.50, "GBP")
: £1.50Вот прям очень интуитивно же:
var z = Money.FromUnit(150.50, "GBP"): £2.00
bugigugi
08.11.2022 12:04Хранение знаков для валют должно быть = количество знаков после запятой у валюты * 2.
LinearLeopard
10.11.2022 14:01Хранение знаков для валют должно быть = количество знаков после запятой у валюты * 2.
Так с какими криптовалютами можно и за 64 бита вывалиться.
Эфир - 18 десятичных знаков, некоторые вообще 30
https://docs.nano.org/protocol-design/distribution-and-units/#
dyadyaSerezha
08.11.2022 12:25+5Например, официальный курс ЦБ (а они-то уж точно знают, сколько копеек в рубле) доллара на сегодня составляет 60,9013 рубля
Курс доллара - не валюта, а соотношение валют. Тщательнее.
yukon39
09.11.2022 00:19+1Это уже вопрос терминологии, что считать денежным значением. Курс доллара это стоимость одного доллара в рублях, цена, можно сказать.
И тут надо учесть, что цена рубля в долларах тоже точное значение 0.0164 доллара за рубль, а не обратная величина стоимости доллара в рублях.
Опять же цена акции Россети сегодня 0.5778 рублей. Это уже денежное значение или отдельная сущность Цена?
dyadyaSerezha
10.11.2022 14:55Это не вопрос терминологии, а принципиальный вопрос. Валюта, курс и цена - суть разные единицы, у них разные размерности, разное логическое значение, разное использование.
zversky
09.11.2022 13:14Меня тоже это всегда удивляло. Все верно. Только вот почему 4 знака после запятой? Не 5, не 15, не 1, а именно четыре?
Какой в этом скрытый смысл?
Не говоря уже о том, что практической пользы эта информация не несет. Основная задача курса - обеспечить конвертацию "арбузов" в "патроны", и при всем своем желании вы не сможете получить или отправить с помощью банковской системы 90.13 копеек - будет округление.VaalKIA
09.11.2022 15:41Потому что на торги выставляют суммы, а не 1 рубль. То есть, условно, меняют 3 рубля на 4 доллара, ставки идут миллиардами, поэтому эти 3 рубля дают возможность менять частями (в данном случае должна быть возможность из этих трёх поменять один) , тогда сразу менять должны были 3 на 4.02, а обмен 3 на 4 тупо запрещён. В итоге, ставки динамически меняются и при вашем подходе градация 0.01 для миллионной ставки даёт весьма приличную дельту и малую предсказуемость получаемой суммы, а счёт идёт на секунды. Поэтому меняют всю сумму ставки на любую величину, а курс обмена, это посто коэффициент.
Фактически, если числа не факториалы, то всегда будет ситуация, когда выкупаемая часть будет порождать нецелые (в копейках), числа, поэтому стремиться к отсутствию округлений нет никакого смысла. А курс нужен таким, что бы продавай несколько сотен миллионов, у вас была погрешность не в пару миллионов.
dyadyaSerezha
10.11.2022 15:52Становитесь председателем центробанка и сделайте 15 знаков после запятой. ????
dyadyaSerezha
08.11.2022 12:22+3var y = Money.FromUnit(100.50, "GBP"): £1.50
Не понял, почему полтора-то?
И в том же прммере, почему x был 1 фунт, а когда стали распечатывать, оказалось полтора?
ValeraBgg
08.11.2022 14:05+1Понимаю, что перевод, но очень хочется сказать "Раз такой умный, возьми и сделай".
Ещё один замечательный пример — деньги! Просто куча приложений выражает денежные значения при помощи типа
decimal
. Почему? У этого типа так много проблем, что такое решение мне кажется непостижимым.Разумеется, для моделирования такого типа
Money
придётся приложить усилия, но после его реализации и тестирования остальная часть кодовой базы будет в гораздо большей безопасности.
linchk
08.11.2022 14:05Тут ведь нет ничего нового, все та же дилема как писать меньше кода и получить меньше ошибок в рантайме. Хорошо бы свалить все на компилятор, чтобы ошибок в принципе не возникало- добро пожаловать в Haskell, с его "маниакальной" строгой типизацией. Для языков с динамической типизацией тоже есть свои библиотеки описания и проверки типов. И если надоело, что код то и дело падает в рантайме, то без строгого контроля за типами данных не обойтись. Только ведь вот какое дело, работы это только добавит. Тут все как в поговорке: "семь раз отмерь ....".
Cerberuser
08.11.2022 20:19+1Только ведь вот какое дело, работы это только добавит.
Работы по написанию - да. Работы по отладке - в каких-то случаях убавит заметно сильнее.
kpmy
08.11.2022 19:42+1Возможно, для указанных проблем подошла бы не классическая система пользовательских типов на базе ООП, а конструируемая логическая система типов на базе OWL (например).
Посмотрите, как вы пытаетесь логические выражения упаковать в названии классов и методов. Нет ли в этом следующей ступени примитивизации, когда всё многообразие типов сводится к НазваниямКлассовМетодов(иПараметров).
Имхо, правильнее (в идеальном мире) было бы иметь такой набор "тегов типа", которые с одной стороны, упрощали бы компилятору технические проверки типов объектов, а с другой стороны позволяли бы выразить сколько угодно сложные цепочки предикатов первого порядка.C их помощью которых мы бы описали все ограничения, налагаемые предметной областью на данный объект. Ведь типизация (если брать её без методов) это определённый способ заузить множество присваиваемых значений.
Soukhinov
09.11.2022 00:24+1Отличная статья.
Добавлю пример из своей практики. Когда-то у меня в коде цвет был представлен в виде примитивного типа
float3
. На первый взгляд проблемы не было, т.к. все значения примитивного типа (даже отрицательные) могли быть цветом. Но были такие вещи:float3 color = getColorDocument(…); color = colorSpace.convertColorDocumentToColorLinear(color); color = colorSpace.convertColorLinearToColorLAB(color); color = someColorLABFunction(color); color = colorSpace.convertColorLinearToColorDocument(color);
Здесь в коде перед последней строчкой я забыл сконвертировать цвет из цветовой модели LAB в цветовую модель Linear, и в программе появился труднообнаружимый баг. Кроме того, не понятно было, цвет какой модели принимают и возвращают функции: приходилось указывать это в комментариях.
Все перечисленные проблемы исчезли, как только я сделал следующее:
struct ColorDocument{float3 rgb;}; struct ColorLinear{float3 rgb;}; struct ColorLAB{float3 lab;}
Но появилась новая проблема: как выполнять над структурами математические операции, которые легко делались над примитивными типами?
Поначалу я хотел всё сделать грамотно, в соответствии с системой типов. Например, разность двух объектов
ColorLinear
порождает объект типаColorLinearDifference
, который цветом уже не является, но его можно прибавить кColorLinear
и получить сноваColorLinear
. Закопался на десятки типов и тысячи строк кода. Получилась полная фигня и куча boilerplate-кода.В итоге выкинул всё, кроме приведённых выше простейших структур, и везде, где нужна математика, напрямую лезу в
.rgb
или в.lab
. Так понятнее, хоть и не полностью «типобезопасно».rtemchenko
09.11.2022 03:49Зачастую есть класс Color, и из него достаются разные репрезентации. И создается класс из разных репрезентаций. И так везде. Можно сделать класс кластер под капотом. А можно прост хранить одну репрезенатцию и гонять туда-сюда.
Soukhinov
09.11.2022 12:41И класс-кластер, и класс с одной репрезентацией имеют накладные расходы во время исполнения. Мне это не подходит. Кроме того, смена репрезентации иногда происходит с потерями. Например, если
ColorDocument
— это sRGB, то он не поддерживает WideGamut, HDR, и отрицательные цвета.Поэтому каждая смена репрезентации в моём случае должна быть явной и обдуманной.
Andrey_Solomatin
10.11.2022 11:21И класс-кластер, и класс с одной репрезентацией имеют накладные расходы во время исполнения. Мне это не подходит.
Расскажите на чём пишете и что за проект.
koperagen
09.11.2022 00:40Думаю, что лучший друг программиста не на идрисе это все таки дебаггер =) Здорово, конечно, когда на уровне типов можно выразить какие-то полезные свойства программы. Но в каких мейнстрим языках это действительно работает кроме как для небольшого списка задач?
Примеры из статьи лично меня не убеждают. Оборачивать строки в обертки это скука и так можно делать, кажется, вообще везде, где есть статические типы. За последний год программирования у меня получилось уместно добавить обертку ровно 1 раз. Даже грустно. Вроде хочу с типами дружить, а толком не получается.
Дайте какой-нибудь показательный пример, который вы написали / увидели и решили, что ну вот здесь система типов (сложнее джавы) круто сработала и у дргого программиста нет шансов как-то неправильно использовать код, допустим. Давно хочу такое найти, но пока безуспешно :с
Andrey_Solomatin
09.11.2022 10:50+1Думаю, что лучший друг программиста не на идрисе это все таки дебаггер =)
Дебаггер, это чтобы разгребать последствия.
Примеры из статьи лично меня не убеждают
А такие вариант работы со строками? https://docs.oracle.com/javase/8/docs/api/java/nio/file/Path.html https://docs.oracle.com/javase/7/docs/api/java/net/URL.html
Здорово, конечно, когда на уровне типов можно выразить какие-то полезные свойства программы.
У этих обёрток есть еще один интересный момент, в коде вы можете выражать мысли на том-же языке, на котором разговариваете с заказчиком. При внесении интеджера на аккаунт, размер интеджера на аккаунте увеличивается.
JeanneD
09.11.2022 13:14Статья прям совсем для новичков, где объясняется нужность ValueObjects.
Придется прочитать довольно много литературы и написать много кода, прежде чем это все усвоится на хорошем уровне.
HabraUser666
09.11.2022 13:14-1Не хочу устраивать холивар, но такой код для сравнения хеша, это просто находка для хакера. Во-первых, его можно просто переполнить, и функция вернет истину. Во-вторых, если вы где-то по пути запихаете нул и замените хеш на нул в базе, то вуаля вы тоже сломаете вход в систему.
mayorovp
09.11.2022 13:45Во-первых, его можно просто переполнить, и функция вернет истину.
Как вы "просто переполните" стандартный массив байт? Особенно учитывая, что его не вводит пользователь, а возвращает хеширующий алгоритм?
Во-вторых, если вы где-то по пути запихаете нул и замените хеш на нул в базе, то вуаля вы тоже сломаете вход в систему.
Снова — как? Это ж не-nullable колонка в базе, как вы туда null запишете? (А если она nullable, значит null там предусмотрен)
И как хакер заменит выход алгоритма хеширования на null?
И, главное, даже если хакеру что-то из перечисленного вами удастся — что он получит с того, что поломает авторизацию себе?
vany200397
09.11.2022 20:34Спасибо, действительно интересно было почитать. Мне порой очень не хватает типа "телефонный номер". После прочтения стал задумываться над созданием такого в каком-нибудь из своих проектов.
cdriper
10.11.2022 13:38Тема, к которой часто любит возвращаться Страуструп, приводя в пример кейс, когда числа можно сопровождать их величинами. И тогда число в километрах разделенное на число часов дает скорость, а в коде, например, есть функции, которые принимают только числа выраженные как скорость.
andToxa
Сразу вспомнилась книга «Безопасно by design»: там много примеров, где пользовательские типы (вплоть до количества заказов в интернет-магазине) помогают писать более безопасный и менее подверженный ошибкам код.
Jianke
Начинается с
что сразу создаёт накладные расходы и ставит крест на скорости. :-(
vvbob
А так часто нам нужна какая-то экстремальная скорость и сверхэкономия памяти?
Думаю те части, в которых это нужно, можно писать опасно, но это должно делаться осознанно.
В остальных же местах приложения, на первый план выходит безопасность (от ошибок и от взломов), какой толк от очень быстрой и нетребовательной к ресурсам программы, если она работает неправильно и выдает ошибочные результаты?
Jianke
А что без неизменяемости всё будет именно работать неправильно с ошибочными результатами?
А сама неизменяемость гарантирует, что всё работать правильно и без ошибок?
masai
Ложная дихотомия. Никто не утверждает ничего подобного. Гарантий нет, но меньше риск допустить ошибку.
vvbob
Пристегнутый ремень в автомобиле гарантирует вам выживание в любой аварии? А если вы его не пристегнете, то обязательно попадете в аварию и погибните?
Это рассуждения одного порядка. Разумеется неизменяемость не гарантирует отсутствие ошибок, так-же как ее неиспользование не означает непременно их наличие. Это просто один из множества приемов, повышающих надежность и снижающих вероятность ошибок.
Не серебряная пуля, просто один из полезных приемов.
Jianke
Неизменяемость - это не ремень безопасности, а "впереди автомобиля должен бежать специальный человек с флагом, фонарём и дудкой" (c) из правил дорожного движения The Locomotive Act 1865 года. :-)
Потому что неизменяемость - это есть ничто иное, как эмуляция ручных вычислений на бесконечном листике бумаги = всё что написано на бумаге неизменяемо!
Расплата за это получается вот такой:
Почему современное ПО такое медленное — разбираемся на примере диктофона Windows
Компьютеры быстры, но вы этого не знаете
Когда старый компьютер лучше нового
Неожиданные причины торможения программ и систем
mayorovp
Ни одна из перечисленных вами проблем не была вызвана неизменяемостью.
vvbob
Все аналогии лгут, как и моя с ремнем безопасности, как и ваша с человеком с дудкой, только ваша врет намного сильнее. Неизменяемость и близко не оказывает такого большого влияния на производительность, какую сопровождающий оказывает влияние на максимальную скорость автомобиля.
Ну а по поводу тормозящего современного софта вы правы в том что он тормозит и часто нерационально организован, только вот одна проблемка - большая часть из вами перечисленного написано как раз без всех этих "новомодных штучек" типа неизменяемости, поэтому аргумент ваш тут совсем не попадает в цель.
MrNutz
Если прогер не в состоянии написать что-то безопасно без помощи фреймворков, готовых библиотек и модулей, то ему стоит уйти в управдомы.
Что же касается скорости и компактности, то есть пара примеров.
Посмотрите на наш прекрасный интернет. Что не сайт, то тормоза и обжорство памятью. Спасибо фреймворкам.
Недавно понадобилось написать объединение двух таблиц по ключу с сортировкой. Ну лень было все это руками ваять. Использовал готовый хэшмэп и чего то ещё. Исходный файл 700мб + чуть-чуть. Так вот в первоначальном варианте это вообще не взлетело, т.к. Jvm отъедала 8 Гб оперативы и падала с ошибкой нехватки памяти. Пришлось один из этап делить на куски. И то это отьедало 5-6 гигов. Но функционал нужен был быстро, конкретно сейчас и без сюрпризов. Иначе бы написал все руками. Работало бы и быстрее и точно более экономно.
mayorovp
Ну и каким местом обсуждаемые типы касаются проблем фреймворков и прожорливости джавы?
F0iL
Спасибо заказчикам, которым почти всегда надо "чтобы было готово еще вчера" и которые не хотят платить за то, чтобы все было сделано по-нормальному, а не херак-херак и в продакшн. Поэтому разработчики клепают по-быстрому на фреймворках и не заботятся об оптимизациях, потому что на это тупо не выделено времени и денег. Так что фреймворки - не причина, а следствие.
DevAdv
Я правильно понимаю, что сами без фреймворков реализовать "функционал" быстро, конкретно сейчас и без сюрпризов вы не можете, но виноваты фреймворки?
masai
Неизменяемость не обязательно ставит крест на скорости. Есть структуры данных, специально заточенные под это.
В функциональных языках неизменяемость очень распространена, поэтому можно, например, посмотреть книжку Криса Окасаки о чисто функциональных структурах данных.
Rsa97
Неизменяемость — это снаружи, то что видит программист. Под капотом интерпретатор/компилятор вполне может использовать под новую переменную ту же память, если проследил, что старая переменная в дальнейшем не используется.
0xd34df00d
Нет, не обязательно создаёт, потому что компилятор нередко может свернуть это во вполне мутабельный код при компиляции. Я уж не говорю про вещи вроде линейных типов, которые могут помогать компилятору генерировать эффективный мутабельный код (или вообще его вырезать).