Comments 30
Имхо, местами с функциональщиной перебор:
public static Weight ofKilograms(int kilograms) {
var inGrams = BigDecimal.valueOf(kilograms)
.multiply(BigDecimal.valueOf(1000));
var rounded = inGrams.setScale(0, RoundingMode.HALF_UP);
return new Weight(rounded.longValueExact());
}
return new Weight (kilograms * 1000L); - вполне достаточно.
Да, вы правы, окрулять не надо если у нас и так целые значения и храним в long.
Мне хотелось этим показать, что можно упаковать в метод правила округления.
Думаю вариант с аргументов double лучше демонстрирует идею:
public static DiscountRate ofFraction(double raw) {
var bd = BigDecimal.valueOf(raw);
var normalized = bd.setScale(2, RoundingMode.HALF_UP);
return new DiscountRate(normalized);
}Хотя если копать дальше, я бы предпочёл округлять и нормализовать scale именно в конструкторе в итоге, тогда точно будет одна точка подготовки данных.
В C# double не даёт нужной точности значения. Всегда использую decimal.
Коммент для треда "ваша джава творит дичь" :)
В Java тоже обычно используют BigDecimal для точности. Но есть бесячая проблемка. Если в C# при сравнении decimal "созданной с разной точностью" сравнения происходят без проблем:
decimal a = 1.1m;
decimal b = 1.10m;
Console.WriteLine(a == b); // true
Console.WriteLine(a.Equals(b)); // true
То в Java разная точность и надо сравнивать через compare, так как через equals не будет true. Поэтому связываться с этим в бизнес логики совсем неинтересно:
var a = new BigDecimal("1.10"); //scale = 2
var b = new BigDecimal("1.1"); //scale = 1
System.out.println(a.equals(b)); //false
System.out.println(a.compareTo(b) == 0); //true
System.out.println(a.setScale(2).equals(b.setScale(2))); //true
Строки взяты для быстрой настройки scale и для точности.
Разве в джаве нет оттестированных библиотек для работы с физическими величинами?
Конечно есть, в Java есть стандарт JSR 385 (Units of Measurement API 2.0) и популярная реализация это библиотека Indriya. Мы его используем, особенно востребована библиотека в "международных" сервисах, когда величины разные и зависят от страны.
В некоторых случаях используется дополнительная обертка над библиотечными классами для добавления собственных свойств и инвариантов. Почему используется не всегда и везде, потому что иногда это избыточно и усложняет код.
Для денег тоже есть устоявшаяся библиотека JSR 354 RI Moneta. И я бы ее рекомендовал для работы с деньгами по умолчанию.
Weight(700)
700 от куда-то взялся, а откуда, и в чем он? 700 грамм? 700 тон?
Хорошее замечание. Было бы разумнее назвать класс "Grams", чтобы существенно повысить читаемость, и избежать подобных вопросов.
Это псевдокод) там нет new если мы говорим о Java синтаксисе. И подразумевает, что объекты созданы на основе одинаковых значений.
Но мне нравится это замечание и я верну синтаксис Java, чтобы не было вопросов и затыков при чтении)
Всем value объекты прекрасны. Кроме как наличием отсутствия перегрузки операторов в Java, чем в общем и BigDecimal характерен. Более-менее сложное вычисление - и хоть в LLM скармливай, чтобы объяснило, что там написано. Особенно когда встречается пара таких объектов, каждый из которых - со своим неповторимым API.
Мы больше не бросаем исключения. Вместо этого явно возвращаем либо ошибку (
Left), либо успешно созданный VO (Right). Больше примеров использования на все случаи жизни вы найдете в документации к библиотекам.
Вот такое я по возможности блокирую на review. Потому что:
где-то должно быть написано, что Left - это ошибка, а Right - это значение. И соблюдаться неукоснительно. В данном случае накосячить с правой стороной сложно, а с левой - "а давайте для случаев, когда нужно возвращать VIP статус, будем там возвращать строчку "VIP", а если не "VIP" - то сообщение об ошибке. А если цифры - то это идентификатор сотрудника, который оформил заказ. И через пару - тройку развивателей продукта, каждому из которых нужно добавить функциональность и по возможности ничего не сломать - получаем псевдо-DSL в поле Left. В более лёгком случае - набор public static final String, которые сравниваются хитрыми if-else в объёме на полноценную state machine.
Исходя из принципов, приведённых в статье: делаем VO ErrorMessage, который инкапсулирует строку с сообщением. Или вообще имеет где-то там enum с этими строками. Either.left им и типизирован, при чтении понятна семантика, да и IDE автоматически сгенерирует переменную нужного типа где-нибудь там потом.
Вариант чуть посложнее, если упороться в рефакторинг: делаем VO для возвращаемого результата, с явными полями Error error и PickupNumber pickupNumber. Пару методов создания - ofError(, ofSuccess( можно ещё ofAlmost( - это когда и создать удалось, но не без сопутствующих ошибок. Кстати, когда попросят собрать все ошибки, warning-и и умолчания, которые случились внутри вызова, этот список можно будет упаковать внутрь Error и не переписывать весь окружающий код.
Таким образом появляется некоторый шанс, что API без присмотра просуществует некоторое время примерно так, как задумывал автор.
где-то должно быть написано, что Left - это ошибка, а Right - это значение
В доке библиотеки написано) By convention the success case is Right and the failure is Left. Это как с assertEquals(a , b) в JUnit, что первое, а что второе. Благо AssertJ упрощает и уже на надо запомнить это соглашение о порядке элементов. И использование внешней библиотеки или подхода должно быть не внезапным событием для ревьювера, а заранее спланированных событием.
Чтобы это не создавало когнитивную нагрузку, то всегда можно сузить возможности для разработчика и отдать это компилятору:
Either<DomainError, T>
В данном случае накосячить с правой стороной сложно, а с левой - "а давайте для случаев, когда нужно возвращать VIP статус, будем там возвращать строчку "VIP", а если не "VIP" - то сообщение об ошибке.
Ок, если рассматривать текст статьи как бездумный копипаст и использовать String, то да. Полностью прав с вашим комментом, но у нас есть же объекты. Вы можете возвращать, то что вам удобно. Можно сделать sealed класс с исключениями DomainError, а там используйте что удобно и готовый текст или код ошибки для дальнейшей локализации ответа. По сути вы это и описали во второй части комментария – делаем систему возвратов строго типизированной. И я разделяю этот подход.
А возможно я тут и сам попал в Primitive Obsession силки 😁 кто знает)
мне кажется или проделана большая работа по натягиванию совы на глобус....
Вам не кажется. Подано как очередная серебряная пуля, а по сути — пшик. Собственно поэтому мой комментарий выше остался без ответа.
Не судите строго) Костя хороший разработчик, просто чтобы апнуться в сдэк нужно показать перед hr-ами активность на конференции или ляпнуть жидкого на хабре
Не-а, не кажется 🙂
Я не предлагаю всем срочно делать VO и что без этого у нас всё развалится. Нет, это просто подход, который может решить некоторые проблемы.
P.S. на коммент ответил :)
То же самое на расте (Result для обработки ошибок и стирание типов при компиляции, в рантайме работает так же быстро, как если бы использовался обычный usize):
use std::ops::{Add, Mul, Sub};
#[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Eq, Ord)]
pub struct Weight(usize);
impl Weight {
#[allow(clippy::unwrap_used)]
pub fn from_grams(grams: i32) -> Result<Self, String> {
if grams < 0 {
Err(format!("Weight cannot be negative: {} g", grams))
} else {
Ok(Self(grams.try_into().unwrap()))
}
}
pub fn from_kilograms(kilograms: i32) -> Result<Self, String> {
Self::from_grams(kilograms * 1000)
}
/// Получить вес в граммах
#[must_use]
pub fn inner(self) -> usize {
self.0
}
#[must_use]
pub fn is_between(self, first: Self, second: Self) -> bool {
let min = first.min(second);
let max = first.max(second);
min <= self && self <= max
}
#[must_use]
pub fn is_empty(self) -> bool {
self.0 == 0
}
}
impl Add for Weight {
type Output = Self;
fn add(self, rhs: Self) -> Self::Output {
Self(self.0 + rhs.0)
}
}
impl Sub for Weight {
type Output = Result<Self, String>;
fn sub(self, rhs: Self) -> Self::Output {
if rhs > self {
Err(format!("Weight cannot be negative: -{} g", rhs.0 - self.0))
} else {
Ok(Self(self.0 - rhs.0))
}
}
}
impl Mul<usize> for Weight {
type Output = Self;
fn mul(self, rhs: usize) -> Self::Output {
Self(self.0 * rhs)
}
}
Так и думал, что речь шла просто о паттерне Newtype. Джависты всегда всё переусложняют.
Отличие есть, крато:
Newtype это только про типовую безопасность. Обернули String → получили другой тип. Не получили overhead, а компилятор не обманешь.
Думаю с учетом примера на Rust его дока и подходит. Rust Design Patterns – Newtype
ValueObject пошире, это можно сказать "усложненный" newtype, включающий инварианты и может содержать не одно поле при необходимости.
😁 И всё это придумали не джависты, не во всем они виноваты)
У меня была похожая проблема в графической системе - какие то функции возвращают координаты TPoint в абсолютных какие то в относительных, какие то в пикселях, какие то в сантиметрах. И все были нужны. Я сделал несколько по разному называющихся типов равнозначных TPoint и везде расставил. Стало понятно по коду в любом месте что функция возвращает.
Пока кто-то не наведет не сделает ревью всего и не приведет в порядок, его не будет.
А вот эти обертки в результате увеличат сложность, так как каждый ленивый разработчик будет приводить к нужному стандарту в своем конкретном месте и в цепочке прохождения все будут преобразовываться туда - сюда по 10 раз.
Вы сделали разные типы? И каждый выводит TPoint? Интересно узнать, что тут значит равнозначные.
Сделал разные алиасы для одного и того же типа
TFloatPoint = TPointF;
TPxPoint = TFloatPoint; { в пикселях }
TSxPoint = TFloatPoint; { в единицах сетки }
TFloatRect = TRectF;
TPxRect = TRectF; { в пикселях }
TSxRect = TRectF; { в единицах сетки }
И при объявлении метода видно в каких единицах он принимает и выдает результат
function GetBasePoint(const LPath: RSDERegion; SegN: integer; R: TPxRect): TPxPoint;
В F# встроенная поддержка есть, впечатляет: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/units-of-measure
Это хорошо, когда есть newtype и прочие Units of Measure - в C# так просто промаркировать существующие value type нельзя, а, значит, нужен новый class/struct/record, а, значит, например, существующую арифметику придется переопределять. Скажем, Weight выше - хотим их сложить или умножить на количество единиц, ан фигу, давай заново пиши сложение и умножение. Семантически это правильно - новый тип определяет допустимые операции (включая равенство, сортировку и пр.), но при этом писанины - мама не горюй. Отдельный геморрой - любая сериализация бинарная/в строку/БД, которая про новые типы знает ничего. Опять или писать расширение или включать слой конвертации обратно в известные примитивы. В целом я совершенно за строгую типизацию (и к DDD она вообще никаким боком), но это совсем не бесплатно.
Для C# есть https://github.com/SteveDunn/Vogen
[ValueObject<int>]
public partial struct CustomerId;Довольно удобно и есть поддержка сериализации для БД
Супер лайк! Когда какой нибудь модный молодежный язык попробует это ввести прямо чтобы встроено было? Типа instant в Java
Уже есть поддержка, вот что пришло в первую очередь:
Kotlin имеет inline value classes — это лёгкие обёртки над примитивами с инвариантами, без overhead на runtime. Пример:
value class Weight(private val grams: Long), компилятор “инлайнит” его как long, но даёт типобезопасность и методы.Rust использует newtype pattern: pub struct Weight(pub u64); — zero-cost абстракция, где компилятор оптимизирует до голого числа, но с отдельным типом для предотвращения ошибок.
Также примеры на C# тоже укладываются в «есть прямо сейчас», а Java ждёт проекта Valhalla. На Хабре писали про это Project Valhalla: эпичный квест Java за перфомансом)
Information
- Website
- rabota.cdek.ru
- Registered
- Employees
- 501–1,000 employees
- Location
- Россия
- Representative
- Марат Каримов
Value Object: как победить примитивную одержимость без DDD