Как я писал в отдельной статье, не стоит хранить деньги в float, лучше использовать объектные обёртки.
Моё любимое решение, это использование объектов-значений (шаблон проектирования “Value Object”) для денег.
Что за Value Object?
Value Object — это объект, главной целью которого будет представление типа данных в системе.
Для простых данных мы можем использовать встроенные типы, например если мы храним в переменной количество заходов на сайт за сутки, то используем int
.
Value Object: день недели, класс WeekDay
Но что если нам потребовались какие-то данные со своей собственной логикой?
Например, день недели.
При написании кода который обрабатывает день недели, нам потребуется учитывать:
- День недели может быть только в диапазоне от 1 до 7
- После воскресенья идёт понедельник
- Перед понедельником идёт воскресенье
Мы можем использовать тип int
для хранения дня недели. Но в этом случае везде где значение передаётся в метод, извлекается из внешних источников, и выполняются операции нам придётся явно вызывать проверки на корректность значения.
Если же мы забудем где-то вызвать проверку, некорректные данные “расползутся” по системе и придётся долго с помощью отладки выяснять, где именно мы пропустили или создали некорректные данные.
Воспользовавшись шаблоном проектирования Value Object, мы можем создать объект, который будет содержать значение дня недели и вызывать встроенную проверку каждый раз при создании значения.
class WeekDay
{
private int $dayOfWeek;
public function __construct(int $dayOfWeek)
{
if (!$this->isValid($dayOfWeek)) {
throw new \LogicException('Некорректный день недели: ' . $dayOfWeek);
}
$this->dayOfWeek = $dayOfWeek;
}
public function nextDay(): self
{
...
}
public function previousDay(): self
{
...
}
public function number(): int
{
return $this->dayOfWeek;
}
...
}
Теперь мы просто не сможем передать некорректное значение, так как при любой попытке создать некорректный объект просто вылетит ошибка.
Поэтому мы можем быть спокойны в любом месте нашего кода где используем этот объект. Мы избавились от лишних проверок и повысили надёжность кода.
Дополнительный плюс — мы можем поместить в объект функции, которые непосредственно с ним связаны, такие как вычисление следующего и предыдущего дня недели. Если у нас был хелпер для этих вычислений, то мы разгрузили его от этой работы. Ну и заодно повысили читаемость кода.
Value Object: деньги, класс Money
Всё то же самое применимо и для денег.
Вместо того чтобы писать проверки в каждом месте где мы оперируем денежными значениями, лучше создать класс Money
, в котором будут все проверки и базовые операции с деньгами.
Я использую расширение PHP bcmath
чтобы избежать работы с float
, поэтому при запуске этого кода убедитесь, что расширение подключено в вашей конфигурации PHP.
class Money
{
private const DECIMAL_SCALE = 2;
private string $value;
private function __construct(string $value)
{
$this->value = $this->normalize($value);
}
public static function createZero(): self
{
return new self('0');
}
public static function createFromString(string $value): self
{
return new self($value);
}
public function toString(): string
{
return $this->value;
}
public function add(Money $amount): Money
{
return self::createFromString(bcadd($this->value, $amount->value, self::DECIMAL_SCALE));
}
public function substract(Money $amount): Money
{
return self::createFromString(bcsub($this->value, $amount->value, self::DECIMAL_SCALE));
}
public function isZero(): bool
{
return $this->equals(self::createZero());
}
public function invertSign(): Money
{
$zero = self::createZero();
return $zero->substract($this);
}
public function equals(Money $money): bool
{
return bccomp($this->value, $money->value, self::DECIMAL_SCALE) === 0;
}
private function normalize(string $value): string
{
if (($value === '') || !preg_match('/^(-)?[0-9]+(\\.[0-9]+)?$/', $value)) {
throw new \LogicException("Неподдерживаемый формат денег: {$value}");
}
return bcadd('0', $value, self::DECIMAL_SCALE);
}
}
Теперь мы можем передавать объекты Money по всему проекту, не переживая за их целостность, ведь проверки уже отработали в момент создания объекта.
Ещё один плюс использования Value Object в том, что переместив проверки внутрь объекта мы упрощаем тестирование.
Вместо того чтобы писать тесты на денежные расчёты по всему проекту, мы покроем тестами лишь один объект, а в остальном коде проекта будем знать что он работает правильно.
Пример использования класса Money
public function transfer(Account $from, Account $to, Money $amount): void
{
$from->writeBalance($from->getBalanceAmount()->substract($amount));
$to->writeBalance($to->getBalanceAmount()->add($amount));
}
Плюсы Value Object
Подведём итоги, что мы получаем благодаря внедрению Value Object.
Повышаем надёжность — мы не ошибёмся забыв сделать проверку, она будет выполнена всегда;
Улучшаем читаемость — вызовы встроенных функций объекта выглядят более опрятно;
Упрощаем тестирование — вместо тестирования логики проверок везде, тестируем только один объект.