Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions docs/guide/arithmetic.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,34 @@ echo Decimal::create('10')->divide('3', 4); // 3.3333
echo Decimal::create('2')->divide('3', 2); // 0.66
```

The result is cut off at the scale, not rounded. Divide with one more digit and round when you need the nearest value:
The result is cut off at the scale, not rounded. Pass a rounding mode as third argument to round it:

```php
echo Decimal::create('2')->divide('3', 3)->round(2); // 0.67
echo Decimal::create('2')->divide('3', 2, Decimal::ROUND_HALF_UP); // 0.67
echo Decimal::create('1')->divide('8', 2, Decimal::ROUND_HALF_EVEN); // 0.12
```

The rounding is decided on the exact quotient. All [rounding modes](./rounding#rounding-modes) work here at the scale, including floor and ceil.

> [!TIP] Added in 1.4
> The rounding mode argument. Without it `divide()` cuts off as before.

### Quotient and remainder

`divideWithRemainder()` returns the whole number quotient and what is left over:

```php
[$quotient, $remainder] = Decimal::create('17')->divideWithRemainder(5);
// 3 and 2

[$quotient, $remainder] = Decimal::create('10')->divideWithRemainder('0.3');
// 33 and 0.1
```

The quotient is cut off towards zero, so the remainder has the sign of the value, like `mod()`: `-7` by `2` gives `-3` and `-1`. Quotient times divisor plus remainder always equals the value.

> [!TIP] Added in 1.4

Dividing by zero throws a `DivisionByZeroError`.

## Modulo
Expand Down
26 changes: 25 additions & 1 deletion docs/guide/creating.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,33 @@ Decimal::create(INF); // Invalid non finite value
Localized strings such as `'1.018,90'` are rejected. Parse them with `NumberFormatter` or your framework's locale tools first.

::: warning Floats
A float is converted with PHP's own float to string rules, which round to the `precision` ini setting. That hides small errors but cannot restore digits the float never had. For money and other exact values, read the number as a string from the request or the database.
A float is converted with PHP's own float to string rules, which round to the `precision` ini setting (14 digits by default). That hides small errors but also drops real digits. For money and other exact values, read the number as a string from the request or the database.
:::

## Floats with all their digits

When a float is all you have, `createFromFloat()` keeps every digit needed to get the same float back, independent of ini settings:

```php
echo Decimal::create(1234567890.1234567); // 1234567890.1235
echo Decimal::createFromFloat(1234567890.1234567); // 1234567890.1234567

echo Decimal::create(0.1 + 0.2); // 0.3
echo Decimal::createFromFloat(0.1 + 0.2); // 0.30000000000000004
```

The second pair shows the trade: the result is what the float really holds, including its error.

## Zero and one

```php
$total = Decimal::zero();
$factor = Decimal::one();
```

> [!TIP] Added in 1.4
> `createFromFloat()`, `zero()` and `one()`.

::: info Exponent limit
Exponents beyond `Decimal::MAX_EXPONENT` (10000 in either direction) throw an `InvalidArgumentException`. Without a limit, the twelve characters of `'1e1000000000'` would expand to a string of a billion digits. A subclass can redefine the constant.
:::
Expand Down
16 changes: 16 additions & 0 deletions docs/guide/rounding.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,22 @@ echo Decimal::create('12.3')->truncate(5); // 12.3

`truncate()` never adds zeros. Use `round()` when the result needs a fixed scale.

## Rounding to a step

`roundToStep()` rounds to the nearest multiple of any positive value, such as five cents for cash payments:

```php
echo Decimal::create('1.03')->roundToStep('0.05'); // 1.05
echo Decimal::create('1.02')->roundToStep('0.05'); // 1.00
echo Decimal::create('7.4')->roundToStep('0.25'); // 7.50

echo Decimal::create('1.01')->roundToStep('0.05', Decimal::ROUND_UP); // 1.05
```

The result has the scale of the step. A step of zero or below throws an `InvalidArgumentException`.

> [!TIP] Added in 1.4

## Rounding to tens and hundreds

A negative scale rounds left of the decimal point:
Expand Down
3 changes: 2 additions & 1 deletion docs/guide/scale.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ The library is strict about it: a value keeps the scale it was created with, and
| `add()`, `subtract()` | larger scale of both operands | `1.50 + 2.125 = 3.625` |
| `multiply()` | sum of both scales | `1.55 * 2.00 = 3.1000` |
| `divide()` | none, the argument is required | `10 / 3` at scale 4 is `3.3333` |
| `divideWithRemainder()` | 0 for the quotient, larger scale of both for the remainder | `10 / 0.3` is `33` and `0.1` |
| `pow()`, `sqrt()`, `mod()` | scale of the value | `1.5 ** 2 = 2.2` |
| `round()`, `truncate()` | the scale you pass | `12.375` rounded to 2 is `12.38` |

Every arithmetic method takes a scale argument to override the default. When the exact result has more digits than the scale allows, the rest is cut off without rounding.
Every arithmetic method takes a scale argument to override the default. When the exact result has more digits than the scale allows, the rest is cut off without rounding. `divide()` rounds when you pass a mode as third argument.

## Repeated multiplication

Expand Down
9 changes: 7 additions & 2 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Class `PhpCollective\DecimalObject\Decimal`, implementing `JsonSerializable` and
| `new Decimal($value, ?int $scale = null, bool $strict = false)` | Creates a decimal. Also accepts any `Stringable` with a numeric string. |
| `Decimal::create($value, ?int $scale = null, bool $strict = false): static` | Same as the constructor, for chaining. |
| `Decimal::tryCreate($value, ?int $scale = null): ?static` | Returns `null` for `null` and for input that is not numeric. |
| `Decimal::createFromFloat(float $value): static` | Keeps all digits of the float, independent of the `precision` ini setting. |
| `Decimal::zero(): static`, `Decimal::one(): static` | The values `0` and `1`. |

See [Creating decimals](../guide/creating).

Expand All @@ -21,7 +23,8 @@ See [Creating decimals](../guide/creating).
| `add($value, ?int $scale = null): static` | Sum | larger of both |
| `subtract($value, ?int $scale = null): static` | Difference | larger of both |
| `multiply($value, ?int $scale = null): static` | Product | sum of both |
| `divide($value, int $scale): static` | Quotient, cut off at `$scale` | required |
| `divide($value, int $scale, ?int $roundMode = null): static` | Quotient, cut off at `$scale`, or rounded when a mode is given | required |
| `divideWithRemainder($value): array` | Whole number quotient and remainder | 0, and larger of both |
| `mod($value, ?int $scale = null): static` | Remainder. `$value` is a `Decimal`, string or integer. | scale of the value |
| `pow($exponent, ?int $scale = null): static` | Power. `$exponent` is a whole number as `Decimal`, string or integer. | scale of the value |
| `sqrt(?int $scale = null): static` | Square root | scale of the value |
Expand Down Expand Up @@ -49,6 +52,7 @@ See [Arithmetic](../guide/arithmetic).
| --- | --- |
| `round(int $scale = 0, int $roundMode = Decimal::ROUND_HALF_UP): static` | Rounds to `$scale` decimal places, or to a whole number with `ROUND_FLOOR` and `ROUND_CEIL`. A negative scale rounds to tens, hundreds and so on. |
| `withScale(int $scale, int $roundMode = Decimal::ROUND_HALF_UP): static` | Pads or rounds to exactly `$scale` decimal places, in any mode. |
| `roundToStep($step, int $roundMode = Decimal::ROUND_HALF_UP): static` | Rounds to the nearest multiple of `$step`. |
| `floor(): static` | Nearest integer towards negative infinity. |
| `ceil(): static` | Nearest integer towards positive infinity. |
| `truncate(int $scale = 0): static` | Cuts off digits beyond `$scale`. |
Expand Down Expand Up @@ -120,7 +124,8 @@ See [Conversion](../guide/conversion).
| Unknown mode in `withScale()` | `InvalidArgumentException` |
| Empty list in `average()`, `min()`, `max()`; invalid ratios in `allocate()`; `clamp()` with `$min` above `$max` | `InvalidArgumentException` |
| Strict mode and the input has more decimals than the scale | `InvalidArgumentException` |
| `divide()` or `mod()` by zero | `DivisionByZeroError` |
| `divide()`, `divideWithRemainder()` or `mod()` by zero | `DivisionByZeroError` |
| Unknown mode in `divide()` or `roundToStep()`; negative scale in `divide()` with a mode; `roundToStep()` with a step of zero or below | `InvalidArgumentException` |
| `toInt()` or `toFloat()` on a value that does not fit | `TypeError` |
| `sqrt()` of a negative value, `pow()` with a fractional exponent, negative scale in an arithmetic method | `ValueError` from bcmath |

Expand Down
3 changes: 3 additions & 0 deletions docs/reference/upgrading.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ PHP 8.2 is now required. Composer keeps PHP 8.1 projects on 1.3.

- Rounding modes `ROUND_HALF_DOWN`, `ROUND_HALF_EVEN`, `ROUND_HALF_ODD`, `ROUND_UP` and `ROUND_DOWN`, see [Rounding](../guide/rounding#rounding-modes)
- `withScale()` to pad or round to a fixed scale, and `round()` with a negative scale
- `divide()` takes a rounding mode as third argument, `divideWithRemainder()` returns quotient and remainder
- `roundToStep()` for multiples such as 0.05
- `Decimal::createFromFloat()`, `zero()` and `one()`
- `Decimal::sum()`, `average()`, `min()` and `max()`
- `split()` and `allocate()` to divide an amount without a remainder getting lost
- `shift()`, `between()`, `clamp()`, `isEven()`, `isOdd()` and `Decimal::tryCreate()`
Expand Down
179 changes: 168 additions & 11 deletions src/Decimal.php
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,51 @@ public static function tryCreate(object|string|float|int|null $value, ?int $scal
}
}

/**
* Creates a decimal from a float with all the digits needed to get the same float back.
*
* Passing a float to create() goes through PHP's string cast and its `precision`
* ini setting, which drops digits beyond the 14th by default.
*
* @param float $value
*
* @throws \InvalidArgumentException If the float is INF or NAN.
*
* @return static
*/
public static function createFromFloat(float $value): static
{
if (!is_finite($value)) {
throw new InvalidArgumentException('Invalid non finite value');
}

$string = '0';
foreach ([15, 16, 17] as $digits) {
$string = sprintf('%.' . $digits . 'H', $value);
if ((float)$string === $value) {
break;
}
}

return new static(str_replace('.0E', 'E', $string));
}

/**
* @return static
*/
public static function zero(): static
{
return new static('0');
}

/**
* @return static
*/
public static function one(): static
{
return new static('1');
}

/**
* @param iterable<object|string|float|int> $values
*
Expand Down Expand Up @@ -613,19 +658,118 @@ public function multiply(self|string|float|int $value, ?int $scale = null): stat
*
* @param self|string|float|int $value
* @param int $scale Decimal places in the result
* @param int|null $roundMode One of the ROUND_* constants. Omit to cut off after $scale places.
*
* @throws \DivisionByZeroError if $value is zero.
* @throws \InvalidArgumentException
*
* @return static
*/
public function divide(self|string|float|int $value, int $scale): static
public function divide(self|string|float|int $value, int $scale, ?int $roundMode = null): static
{
$decimal = static::create($value);
if ($decimal->isZero()) {
throw new DivisionByZeroError('Cannot divide by zero. Only Chuck Norris can!');
}
if ($roundMode === null) {
return new static(bcdiv((string)$this, (string)$decimal, $scale));
}
if ($scale < 0) {
throw new InvalidArgumentException('Scale must be >= 0.');
}

return new static($this->roundedQuotient($decimal, $scale, $roundMode), $scale);
}

/**
* Whole number quotient and what is left over: quotient * $value + remainder equals this value.
*
* The quotient is cut off towards zero, so the remainder has the sign of this value, like mod().
*
* @param self|string|float|int $value
*
* @throws \DivisionByZeroError if $value is zero.
*
* @return array{0: static, 1: static} Quotient and remainder
*/
public function divideWithRemainder(self|string|float|int $value): array
{
$decimal = static::create($value);
if ($decimal->isZero()) {
throw new DivisionByZeroError('Cannot divide by zero. Only Chuck Norris can!');
}

return new static(bcdiv((string)$this, (string)$decimal, $scale));
$scale = max($this->fractionalLength(), $decimal->fractionalLength());
$quotient = bcdiv((string)$this, (string)$decimal, 0);
$remainder = bcsub((string)$this, bcmul($quotient, (string)$decimal, $scale), $scale);

return [new static($quotient), new static($remainder)];
}

/**
* Rounds to the nearest multiple of $step, for example 0.05 for cash amounts.
*
* @param self|string|float|int $step Greater than zero
* @param int $roundMode One of the ROUND_* constants
*
* @throws \InvalidArgumentException
*
* @return static
*/
public function roundToStep(self|string|float|int $step, int $roundMode = self::ROUND_HALF_UP): static
{
$step = static::create($step);
if (!$step->isPositive()) {
throw new InvalidArgumentException('Step must be greater than zero.');
}

$steps = $this->roundedQuotient($step, 0, $roundMode);
$scale = $step->fractionalLength();

return new static(bcmul($steps, (string)$step, $scale), $scale);
}

/**
* @param self $divisor Not zero
* @param int $scale
* @param int $roundMode
*
* @throws \InvalidArgumentException
*
* @return string This value divided by $divisor, rounded to $scale decimal places.
*/
protected function roundedQuotient(self $divisor, int $scale, int $roundMode): string
{
if (!in_array($roundMode, static::roundModes(), true)) {
throw new InvalidArgumentException('Unsupported round mode `' . $roundMode . '`.');
}

// Work on whole numbers so the remainder tells exactly whether and how to round.
$places = max($this->fractionalLength(), $divisor->fractionalLength());
$numerator = $this->shift($places)->integralPart . str_repeat('0', $scale);
$denominator = $divisor->shift($places)->integralPart;

$quotient = bcdiv($numerator, $denominator, 0);
$remainder = bcsub($numerator, bcmul($quotient, $denominator, 0), 0);

$exact = $remainder === '0';
$half = $exact ? -1 : bccomp(bcmul($remainder, '2', 0), $denominator, 0);
$odd = (int)substr($quotient, -1) % 2 === 1;
$negative = $this->negative !== $divisor->negative;

if (static::roundsAwayFromZero($roundMode, $negative, $exact, $half, $odd)) {
$quotient = bcadd($quotient, '1', 0);
}

return (string)(new static(($negative ? '-' : '') . $quotient))->shift(-$scale);
}

/**
* @return int Number of decimals actually stored, which a scale of 0 does not limit in 1.x.
*/
protected function fractionalLength(): int
{
return strlen($this->fractionalPart);
}

/**
Expand Down Expand Up @@ -792,21 +936,34 @@ protected function roundToScale(int $scale, int $roundMode): string
$half = $exact ? -1 : bccomp('0.' . $fractionalPart, '0.5', strlen($fractionalPart));
$odd = (int)substr($integralPart, -1) % 2 === 1;

$awayFromZero = match ($roundMode) {
static::ROUND_FLOOR => !$exact && $this->negative,
static::ROUND_CEIL => !$exact && !$this->negative,
if (static::roundsAwayFromZero($roundMode, $this->negative, $exact, $half, $odd)) {
$integralPart = bcadd($integralPart, '1', 0);
}

return (string)(new static(($this->negative ? '-' : '') . $integralPart))->shift(-$scale);
}

/**
* @param int $roundMode
* @param bool $negative
* @param bool $exact If there is nothing to round.
* @param int $half -1, 0 or 1 for a rest below, at or above the half way point
* @param bool $odd If the last kept digit is odd.
*
* @return bool If the last kept digit has to move one step away from zero.
*/
protected static function roundsAwayFromZero(int $roundMode, bool $negative, bool $exact, int $half, bool $odd): bool
{
return match ($roundMode) {
static::ROUND_FLOOR => !$exact && $negative,
static::ROUND_CEIL => !$exact && !$negative,
static::ROUND_UP => !$exact,
static::ROUND_DOWN => false,
static::ROUND_HALF_DOWN => $half > 0,
static::ROUND_HALF_EVEN => $half > 0 || ($half === 0 && $odd),
static::ROUND_HALF_ODD => $half > 0 || ($half === 0 && !$odd),
default => $half >= 0,
};
if ($awayFromZero) {
$integralPart = bcadd($integralPart, '1', 0);
}

return (string)(new static(($this->negative ? '-' : '') . $integralPart))->shift(-$scale);
}

/**
Expand Down Expand Up @@ -842,7 +999,7 @@ public function allocate(array $ratios): array
throw new InvalidArgumentException('Ratios must add up to more than zero.');
}

$scale = $this->scale();
$scale = max($this->scale(), $this->fractionalLength());
$units = $this->absolute()->shift($scale)->integralPart;

$shares = [];
Expand Down
Loading
Loading