diff --git a/docs/guide/arithmetic.md b/docs/guide/arithmetic.md
index 2f2731b..a1cc353 100644
--- a/docs/guide/arithmetic.md
+++ b/docs/guide/arithmetic.md
@@ -1,6 +1,6 @@
# Arithmetic
-Every operand can be a `Decimal`, a numeric string, an integer or a float. The result is always a new `Decimal`.
+Every operand can be a `Decimal`, a numeric string or an integer. The result is always a new `Decimal`.
```php
use PhpCollective\DecimalObject\Decimal;
@@ -8,44 +8,63 @@ use PhpCollective\DecimalObject\Decimal;
$a = Decimal::create('1.50');
```
+## Exact by default
+
+No operation drops a digit on its own. Addition, subtraction and multiplication return the exact result. Where the exact result cannot be written down, as in most divisions, you say how to round. If you do not, the call throws a `RoundingNecessaryException`.
+
+> [!TIP] Changed in 2.0
+> Results used to be cut off at the scale without notice. See [Upgrading](../reference/upgrading#from-1-x-to-2-0).
+
## Add and subtract
```php
-echo $a->add('2.125'); // 3.625
+echo $a->add('2.125'); // 3.625
echo Decimal::create('5')->subtract('7.5'); // -2.5
```
-The result has the larger scale of the two operands.
+The result has the larger scale of the two operands, which is always exact.
## Multiply
```php
-echo Decimal::create('1.55')->multiply('2.00'); // 3.1000
-echo Decimal::create('1.55')->multiply('2.00', 2); // 3.10
+echo Decimal::create('1.55')->multiply('2.00'); // 3.1000
+```
+
+The scales of both operands are added, which is always enough to hold the exact product.
+
+## Asking for a scale
+
+`add()`, `subtract()`, `multiply()` and `mod()` take a scale as second argument and a [rounding mode](./rounding#rounding-modes) as third:
+
+```php
+echo Decimal::create('1.55')->multiply('2.00', 2); // 3.10
+echo Decimal::create('19.99')->multiply('0.19', 2, Decimal::ROUND_HALF_UP); // 3.80
```
-Without a scale argument the scales of both operands are added, which is always enough to hold the exact product.
+The first line needs no mode because only zeros are dropped. The second would throw without one, since `3.7981` does not fit two decimals.
## Divide
`divide()` requires the scale, because a quotient can have endless decimals:
```php
-echo Decimal::create('10')->divide('3', 4); // 3.3333
-echo Decimal::create('2')->divide('3', 2); // 0.66
+echo Decimal::create('10')->divide('4', 2); // 2.50
```
-The result is cut off at the scale, not rounded. Pass a rounding mode as third argument to round it:
+That quotient fits. Most do not, and then a rounding mode is needed:
```php
-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
+Decimal::create('10')->divide('3', 4);
+// RoundingNecessaryException: The result does not fit the scale.
+
+echo Decimal::create('10')->divide('3', 4, Decimal::ROUND_HALF_UP); // 3.3333
+echo Decimal::create('2')->divide('3', 2, Decimal::ROUND_HALF_UP); // 0.67
+echo Decimal::create('2')->divide('3', 2, Decimal::ROUND_DOWN); // 0.66
```
-The rounding is decided on the exact quotient. All [rounding modes](./rounding#rounding-modes) work here at the scale, including floor and ceil.
+The rounding is decided on the exact quotient, so ties are detected correctly at any scale. `ROUND_DOWN` gives the cut-off result of earlier versions.
-> [!TIP] Added in 1.4
-> The rounding mode argument. Without it `divide()` cuts off as before.
+Dividing by zero throws a `DivisionByZeroException`.
### Quotient and remainder
@@ -61,10 +80,6 @@ The rounding is decided on the exact quotient. All [rounding modes](./rounding#r
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
```php
@@ -73,23 +88,46 @@ echo Decimal::create('-7')->mod(2); // -1
echo Decimal::create('10')->mod('0.3'); // 0.1
```
-The sign follows the left operand, as with PHP's `%`. The result has the larger scale of both operands. A zero operand throws a `DivisionByZeroError`.
+The sign follows the left operand, as with PHP's `%`. The result has the larger scale of both operands. A zero operand throws a `DivisionByZeroException`.
+
+## Power
+
+```php
+echo Decimal::create('8')->pow(2); // 64
+echo Decimal::create('1.5')->pow(2); // 2.25
+echo Decimal::create('1.5')->pow(3); // 3.375
+```
+
+A positive exponent gives the exact result. Pass a scale and a mode to shorten it:
-## Power and square root
+```php
+echo Decimal::create('1.5')->pow(2, 1, Decimal::ROUND_HALF_UP); // 2.3
+```
+
+A negative exponent is a division. It uses the scale you pass, or the scale of the value, and needs a mode unless the result fits:
```php
-echo Decimal::create('8')->pow(2); // 64
-echo Decimal::create('1.5')->pow(2); // 2.2
-echo Decimal::create('1.5')->pow(2, 2); // 2.25
-echo Decimal::create('2')->pow(-1, 2); // 0.50
+echo Decimal::create('2')->pow(-1, 2); // 0.50
+echo Decimal::create('3')->pow(-1, 4, Decimal::ROUND_HALF_UP); // 0.3333
+```
-echo Decimal::create('18')->sqrt(); // 4
-echo Decimal::create('18')->sqrt(5); // 4.24264
+The exponent must be a whole number between -10000 and 10000 (`Decimal::MAX_EXPONENT`). Anything else throws an `InvalidArgumentException`.
+
+## Square root
+
+```php
+echo Decimal::create('64')->sqrt(); // 8
+echo Decimal::create('2.25')->sqrt(); // 1.50
```
-Both default to the scale of the value they are called on and cut off further digits, so pass a scale when the result needs more decimals than the input has.
+The result has the scale of the value unless you pass one. A root that does not fit needs a mode:
-The exponent of `pow()` must be a whole number, and `sqrt()` of a negative value is not defined. bcmath reports both with a `ValueError`.
+```php
+echo Decimal::create('18')->sqrt(5, Decimal::ROUND_HALF_UP); // 4.24264
+echo Decimal::create('18')->sqrt(3, Decimal::ROUND_HALF_UP); // 4.243
+```
+
+A negative value throws a `NegativeNumberException`.
## Sign
@@ -111,13 +149,14 @@ echo Decimal::create('12.34')->shift(-3); // 0.01234
## Several values at once
```php
-echo Decimal::sum(['1.5', 2, Decimal::create('3.25')]); // 6.75
-echo Decimal::average(['1', '2', '4'], 2); // 2.33
-echo Decimal::min(['2.5', '-1', 7]); // -1
-echo Decimal::max(['2.5', '-1', 7]); // 7
+echo Decimal::sum(['1.5', 2, Decimal::create('3.25')]); // 6.75
+echo Decimal::average(['1', '2', '3'], 2); // 2.00
+echo Decimal::average(['1', '2', '4'], 2, Decimal::ROUND_HALF_UP); // 2.33
+echo Decimal::min(['2.5', '-1', 7]); // -1
+echo Decimal::max(['2.5', '-1', 7]); // 7
```
-All four take any iterable. `sum()` of nothing is `0`. `average()`, `min()` and `max()` throw an `InvalidArgumentException` for an empty list. `average()` needs the scale for the same reason `divide()` does, and cuts off there.
+All four take any iterable. `sum()` of nothing is `0`. `average()`, `min()` and `max()` throw an `InvalidArgumentException` for an empty list. `average()` is a division: it needs the scale, and a mode when the mean does not fit.
## Splitting an amount
@@ -138,9 +177,6 @@ The shares always sum to the original value. They have its scale, which also set
Ratios can be integers or decimals such as `'0.7'` and `'0.3'`. Negative ratios, an empty list and ratios that sum to zero throw an `InvalidArgumentException`.
-> [!TIP] Added in 1.4
-> `shift()`, `sum()`, `average()`, `min()`, `max()`, `split()` and `allocate()`.
-
## Chaining
```php
diff --git a/docs/guide/comparison.md b/docs/guide/comparison.md
index ad2748d..e19a503 100644
--- a/docs/guide/comparison.md
+++ b/docs/guide/comparison.md
@@ -16,7 +16,7 @@ var_dump($value->greaterThanOrEquals('1.5')); // bool(true)
var_dump($value->lessThanOrEquals('1.5')); // bool(true)
```
-Each method accepts a `Decimal`, a numeric string, an integer or a float.
+Each method accepts a `Decimal`, a numeric string or an integer.
`compareTo()` returns `-1`, `0` or `1` and fits `usort()`:
diff --git a/docs/guide/conversion.md b/docs/guide/conversion.md
index 3845158..c5b634e 100644
--- a/docs/guide/conversion.md
+++ b/docs/guide/conversion.md
@@ -37,7 +37,7 @@ A JSON number would be read back as a float by most clients, which defeats the p
echo Decimal::create('-23.74')->toInt(); // -23
```
-Values outside the range of a PHP integer throw a `TypeError`. Check with `isBigInteger()` first when the value may be large:
+Values outside the range of a PHP integer throw an `OverflowException`. Check with `isBigInteger()` first when the value may be large:
```php
$value = Decimal::create('9223372036854775808');
@@ -53,7 +53,16 @@ if (!$value->isBigInteger()) {
var_dump(Decimal::create('-23.44')->toFloat()); // float(-23.44)
```
-A float is an approximation. Use it for display libraries and charts, not for further calculation. `toFloat()` throws a `TypeError` when `isBigDecimal()` is true, which is the case when the integral or the fractional digits exceed `PHP_INT_MAX`.
+A float holds about 15 significant digits, so longer values come back as the nearest float:
+
+```php
+var_dump(Decimal::create('0.12345678901234567890')->toFloat()); // float(0.12345678901234568)
+```
+
+Use it for display libraries and charts, not for further calculation. A value beyond the float range throws an `OverflowException`.
+
+> [!TIP] Changed in 2.0
+> `toFloat()` used to refuse values with long fractions, and both conversions threw a `TypeError`. See [Upgrading](../reference/upgrading#from-1-x-to-2-0).
## Scientific notation
@@ -66,6 +75,17 @@ echo Decimal::create('0')->toScientific(); // 0e0
Decimals are kept as written, so the scale survives a round trip through `Decimal::create()`. Call `trim()` first for the shortest form.
+## Serialization
+
+`serialize()` stores the decimal string only:
+
+```php
+echo serialize(Decimal::create('19.90'));
+// O:35:"PhpCollective\DecimalObject\Decimal":1:{s:5:"value";s:5:"19.90";}
+```
+
+Objects serialized by 1.x are still read. For storage that other systems read too, prefer the plain string.
+
## Localized output
The object has no formatting of its own. Pass the string to `NumberFormatter` or to your framework's number helper. Most formatters convert to float internally, so check their precision limits for large values.
diff --git a/docs/guide/creating.md b/docs/guide/creating.md
index 3e16576..f54a032 100644
--- a/docs/guide/creating.md
+++ b/docs/guide/creating.md
@@ -15,10 +15,11 @@ Both forms take the same arguments. `create()` reads better in a chain and also
| --- | --- | --- |
| Decimal string | `'-5.000067'` | `-5.000067` |
| Integer | `-25000` | `-25000` |
-| Float | `1.1` | `1.1` |
| Scientific notation | `'6.22e8'`, `'2.2e-6'` | `622000000`, `0.0000022` |
| `Stringable` object with a numeric string | another `Decimal` | same value |
+Floats are not accepted, see [Floats](#floats).
+
Strings are normalized, the value stays the same:
| Input | Result |
@@ -35,47 +36,34 @@ Trailing zeros are kept. `'5.10'` has a scale of 2, and the object will not drop
## Invalid input
-Anything that is not a plain number throws an `InvalidArgumentException`:
+Anything that is not a plain number throws an `InvalidNumberException`:
```php
Decimal::create('abc'); // Invalid non numeric value
Decimal::create('1,5'); // Invalid non numeric value
-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 (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.
+::: info Exponent limit
+Exponents beyond `Decimal::MAX_EXPONENT` (10000 in either direction) throw an `InvalidNumberException`. Without a limit, the twelve characters of `'1e1000000000'` would expand to a string of a billion digits. A subclass can redefine the constant.
:::
-## 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
-```
+## Floats
-The second pair shows the trade: the result is what the float really holds, including its error.
+A float passed to the constructor, to `create()` or to any arithmetic method throws an `InvalidArgumentException`. A float has usually lost digits before it arrives, and the library refuses to guess which ones matter.
-## Zero and one
+Read exact values as strings from the request or the database. When a float is all you have, convert it on purpose:
```php
-$total = Decimal::zero();
-$factor = Decimal::one();
+echo Decimal::createFromFloat(1234567890.1234567); // 1234567890.1234567
+echo Decimal::createFromFloat(0.1 + 0.2); // 0.30000000000000004
```
-> [!TIP] Added in 1.4
-> `createFromFloat()`, `zero()` and `one()`.
+`createFromFloat()` keeps every digit needed to get the same float back, independent of ini settings and locale. The second line shows what that means: you get what the float really holds, including its binary error. Round the result to the scale you expect.
-::: 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.
-:::
+> [!TIP] Changed in 2.0
+> Floats were accepted everywhere and cut to 14 significant digits. See [Upgrading](../reference/upgrading#from-1-x-to-2-0).
## Optional input
@@ -88,41 +76,43 @@ Decimal::tryCreate(''); // null
Decimal::tryCreate(null); // null
```
-This fits optional form fields and nullable columns.
+This fits optional form fields and nullable columns. A float still throws, because that is a mistake in the code and not in the input.
+
+## Zero and one
-> [!TIP] Added in 1.4
-> `tryCreate()` and the exponent limit.
+```php
+$total = Decimal::zero();
+$factor = Decimal::one();
+```
## Setting a scale
-The second argument fixes the number of decimal places:
+The second argument fixes the number of decimal places. The string always shows it:
```php
-echo Decimal::create('0.129', 2); // 0.12
+echo Decimal::create('0.1', 4); // 0.1000
+echo Decimal::create('5', 2); // 5.00
+echo Decimal::create('0.1200', 2); // 0.12
```
-Digits beyond the scale are cut off, not rounded. A scale of `0` cuts off all decimals. Use [`round()`](./rounding) when you want rounding.
-
-A scale larger than the input records the scale without padding the string. Results of later calculations use it:
+A smaller scale is accepted as long as only zeros are dropped. Losing any other digit throws a `RoundingNecessaryException`:
```php
-$value = Decimal::create('0.1', 4);
-
-echo $value; // 0.1
-echo $value->scale(); // 4
-echo $value->add('0.2'); // 0.3000
+Decimal::create('0.129', 2);
+// RoundingNecessaryException: The value has more than `2` decimals.
```
-### Strict mode
-
-Pass `true` as the third argument to refuse input that would lose digits:
+Say how to round instead:
```php
-Decimal::create('0.123', 2, true);
-// InvalidArgumentException: Loss of precision detected. Detected scale `3` > `2` as defined.
+echo Decimal::create('0.129')->withScale(2); // 0.13
+echo Decimal::create('0.129')->withScale(2, Decimal::ROUND_DOWN); // 0.12
```
-This is useful when the scale mirrors a database column and silent truncation would hide a bug.
+This matters when the scale mirrors a database column: input with too many decimals is reported instead of being shortened without notice.
+
+> [!TIP] Changed in 2.0
+> A scale used to cut off surplus digits and did not pad the string. The `$strict` argument is gone; its check is now always on. See [Upgrading](../reference/upgrading#from-1-x-to-2-0).
## Subclassing
diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md
index 1fa57be..8b6cf72 100644
--- a/docs/guide/getting-started.md
+++ b/docs/guide/getting-started.md
@@ -42,18 +42,18 @@ if ($gross->greaterThan('20')) {
}
```
-`multiply()` returned `3.7981` here, with the scales of both operands added up. `round(2)` brings it back to two decimal places. [Precision and scale](./scale) explains the rules for each operation.
+`multiply()` returned the exact product `3.7981` here, with the scales of both operands added up. `round(2)` brings it back to two decimal places. Nothing is rounded unless you ask for it. [Precision and scale](./scale) explains the rules for each operation.
## Use strings for input
-Pass decimal strings whenever you can. A float has already lost digits before the object sees it:
+Pass decimal strings. A float has usually lost digits before the object could see it, so floats are rejected:
```php
-echo Decimal::create('1234567890.1234567'); // 1234567890.1234567
-echo Decimal::create(1234567890.1234567); // 1234567890.1235
+Decimal::create(19.90); // InvalidArgumentException
+Decimal::create('19.90'); // 19.90
```
-See [Creating decimals](./creating) for all accepted input.
+See [Creating decimals](./creating) for all accepted input and for converting a float on purpose.
## Where to go next
diff --git a/docs/guide/rounding.md b/docs/guide/rounding.md
index 7b48e33..59d13be 100644
--- a/docs/guide/rounding.md
+++ b/docs/guide/rounding.md
@@ -34,6 +34,7 @@ Pass a mode as second argument. Every mode rounds at the given scale:
| `Decimal::ROUND_DOWN` | towards zero | `2` | `3` | `-2` | `2` |
| `Decimal::ROUND_CEIL` | towards positive infinity | `3` | `4` | `-2` | `3` |
| `Decimal::ROUND_FLOOR` | towards negative infinity | `2` | `3` | `-3` | `2` |
+| `Decimal::ROUND_UNNECESSARY` | refuses to round | throws | throws | throws | throws |
```php
echo Decimal::create('0.125')->round(2, Decimal::ROUND_HALF_EVEN); // 0.12
@@ -44,6 +45,30 @@ echo Decimal::create('-12.375')->round(1, Decimal::ROUND_FLOOR); // -12.4
`ROUND_HALF_EVEN` is the banker's rounding used in accounting to avoid a drift over many values.
+`ROUND_UNNECESSARY` asserts that nothing has to be rounded. It pads and drops zeros, and throws a `RoundingNecessaryException` for anything else. It is the default of the arithmetic methods; `round()` and `withScale()` default to `ROUND_HALF_UP`.
+
+### Native rounding modes
+
+On PHP 8.4 and newer every method that takes a mode also accepts PHP's own `RoundingMode` enum:
+
+```php
+echo Decimal::create('2.5')->round(0, \RoundingMode::HalfEven); // 2
+echo Decimal::create('2')->divide('3', 2, \RoundingMode::HalfAwayFromZero); // 0.67
+```
+
+| `RoundingMode` | Constant |
+| --- | --- |
+| `HalfAwayFromZero` | `ROUND_HALF_UP` |
+| `HalfTowardsZero` | `ROUND_HALF_DOWN` |
+| `HalfEven` | `ROUND_HALF_EVEN` |
+| `HalfOdd` | `ROUND_HALF_ODD` |
+| `AwayFromZero` | `ROUND_UP` |
+| `TowardsZero` | `ROUND_DOWN` |
+| `PositiveInfinity` | `ROUND_CEIL` |
+| `NegativeInfinity` | `ROUND_FLOOR` |
+
+The constants keep working on every supported PHP version, so code written for PHP 8.2 needs no change later.
+
The constants have their own values. PHP's `PHP_ROUND_HALF_EVEN`, `PHP_ROUND_HALF_ODD` and `PHP_ROUND_HALF_DOWN` are not the same numbers. An unknown mode throws an `InvalidArgumentException`.
> [!TIP] Changed in 2.0
diff --git a/docs/guide/scale.md b/docs/guide/scale.md
index 1145340..34bbcb8 100644
--- a/docs/guide/scale.md
+++ b/docs/guide/scale.md
@@ -8,20 +8,21 @@ use PhpCollective\DecimalObject\Decimal;
echo Decimal::create('1.50')->scale(); // 2
```
-The library is strict about it: a value keeps the scale it was created with, and trailing zeros count. `1.50` and `1.5` are equal in value but carry different scales.
+The library is strict about it. A value keeps the scale it was created with, trailing zeros count, and the string always shows exactly that many decimals. `1.50` and `1.5` are equal in value but carry different scales.
## Scale of a result
-| Operation | Default scale | Example |
+| Operation | Scale without a scale argument | Example |
| --- | --- | --- |
| `add()`, `subtract()`, `mod()` | 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` |
+| `pow()` | scale times the exponent | `1.5 ** 2 = 2.25` |
+| `divide()` | none, the argument is required | `10 / 4` at scale 2 is `2.50` |
| `divideWithRemainder()` | 0 for the quotient, larger scale of both for the remainder | `10 / 0.3` is `33` and `0.1` |
-| `pow()`, `sqrt()` | scale of the value | `1.5 ** 2 = 2.2` |
-| `round()`, `truncate()` | the scale you pass | `12.375` rounded to 2 is `12.38` |
+| `sqrt()` | scale of the value | root of `2.25` is `1.50` |
+| `round()`, `withScale()`, `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. `divide()` rounds when you pass a mode as third argument.
+Every row above the line for `divide()` is exact. No digit is lost unless you ask for a smaller scale and name a [rounding mode](./rounding#rounding-modes). Asking without a mode throws a `RoundingNecessaryException` when a digit other than zero would go.
## Repeated multiplication
@@ -33,19 +34,20 @@ $value = Decimal::create('1.5');
echo $value->multiply('1.5')->multiply('1.5'); // 3.375
```
-Pass the scale you want, or round at the end of the calculation:
+Pass the scale and mode you want, or round at the end of the calculation:
```php
-echo Decimal::create('19.99')->multiply('0.19'); // 3.7981
-echo Decimal::create('19.99')->multiply('0.19')->round(2); // 3.80
+echo Decimal::create('19.99')->multiply('0.19'); // 3.7981
+echo Decimal::create('19.99')->multiply('0.19', 2, Decimal::ROUND_HALF_UP); // 3.80
+echo Decimal::create('19.99')->multiply('0.19')->round(2); // 3.80
```
## Division loses information
-A cut-off quotient does not multiply back to the original:
+A rounded quotient does not multiply back to the original:
```php
-echo Decimal::create('100')->divide('3', 2)->multiply(3); // 99.99
+echo Decimal::create('100')->divide('3', 2, Decimal::ROUND_DOWN)->multiply(3); // 99.99
```
To split an amount without losing a cent, use [`split()` or `allocate()`](./arithmetic#splitting-an-amount).
@@ -85,4 +87,4 @@ echo Decimal::create('99999999999999999999.99')->add('0.01');
// 100000000000000000000.00
```
-`isBigInteger()` and `isBigDecimal()` tell you whether a value still fits PHP's native types. See [Conversion](./conversion).
+`isBigInteger()` tells you whether a value still fits a PHP integer. See [Conversion](./conversion).
diff --git a/docs/reference/api.md b/docs/reference/api.md
index a13f546..0659c5f 100644
--- a/docs/reference/api.md
+++ b/docs/reference/api.md
@@ -2,32 +2,36 @@
Class `PhpCollective\DecimalObject\Decimal`, implementing `JsonSerializable` and `Stringable`.
-`$value` arguments accept a `Decimal`, a numeric string, an integer or a float unless noted. Methods that return a `Decimal` return a new instance of the called class.
+`$value` arguments accept a `Decimal`, a numeric string or an integer. A float throws, see [Floats](../guide/creating#floats). Methods that return a `Decimal` return a new instance of the called class.
+
+`$roundMode` arguments accept a `Decimal::ROUND_*` constant or, on PHP 8.4+, a native `RoundingMode`.
## Creation
| Method | Description |
| --- | --- |
-| `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. |
+| `new Decimal($value, ?int $scale = null)` | Creates a decimal. Also accepts any `Stringable` with a numeric string. |
+| `Decimal::create($value, ?int $scale = null): 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::createFromFloat(float $value): static` | The only way to pass a float. Keeps all its digits. |
| `Decimal::zero(): static`, `Decimal::one(): static` | The values `0` and `1`. |
See [Creating decimals](../guide/creating).
## Arithmetic
-| Method | Description | Default scale |
+Results are exact. A `$scale` smaller than the exact result needs a `$roundMode`, otherwise a `RoundingNecessaryException` is thrown.
+
+| Method | Description | Scale without `$scale` |
| --- | --- | --- |
-| `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, ?int $roundMode = null): static` | Quotient, cut off at `$scale`, or rounded when a mode is given | required |
+| `add($value, ?int $scale = null, $roundMode = ROUND_UNNECESSARY): static` | Sum | larger of both |
+| `subtract($value, ?int $scale = null, $roundMode = ROUND_UNNECESSARY): static` | Difference | larger of both |
+| `multiply($value, ?int $scale = null, $roundMode = ROUND_UNNECESSARY): static` | Product | sum of both |
+| `divide($value, int $scale, $roundMode = ROUND_UNNECESSARY): static` | Quotient | 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. | larger of both |
-| `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 |
+| `mod($value, ?int $scale = null, $roundMode = ROUND_UNNECESSARY): static` | Remainder | larger of both |
+| `pow($exponent, ?int $scale = null, $roundMode = ROUND_UNNECESSARY): static` | Power, `$exponent` a whole number | exact for a positive exponent, scale of the value for a negative one |
+| `sqrt(?int $scale = null, $roundMode = ROUND_UNNECESSARY): static` | Square root | scale of the value |
| `shift(int $places): static` | Moves the decimal point, multiplying by a power of ten | follows the digits |
| `absolute(): static` | Value without sign | unchanged |
| `negate(): static` | Value with the opposite sign | unchanged |
@@ -35,7 +39,7 @@ See [Creating decimals](../guide/creating).
| Static method | Description |
| --- | --- |
| `Decimal::sum(iterable $values): static` | Sum, `0` for an empty list |
-| `Decimal::average(iterable $values, int $scale): static` | Mean, cut off at `$scale` |
+| `Decimal::average(iterable $values, int $scale, $roundMode = ROUND_UNNECESSARY): static` | Mean at `$scale` |
| `Decimal::min(iterable $values): static` | Smallest value |
| `Decimal::max(iterable $values): static` | Largest value |
@@ -50,25 +54,27 @@ See [Arithmetic](../guide/arithmetic).
| Method | Description |
| --- | --- |
-| `round(int $scale = 0, int $roundMode = Decimal::ROUND_HALF_UP): static` | Rounds to `$scale` decimal places in the given mode. 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. |
-| `roundToStep($step, int $roundMode = Decimal::ROUND_HALF_UP): static` | Rounds to the nearest multiple of `$step`. |
+| `round(int $scale = 0, $roundMode = ROUND_HALF_UP): static` | Rounds to `$scale` decimal places. A negative scale rounds to tens, hundreds and so on. |
+| `withScale(int $scale, $roundMode = ROUND_HALF_UP): static` | Pads or rounds to exactly `$scale` decimal places. |
+| `roundToStep($step, $roundMode = 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`. |
| `trim(): static` | Removes trailing zeros after the decimal point. |
-| Constant | Value |
-| --- | --- |
-| `Decimal::ROUND_HALF_UP` | `1`, same as `PHP_ROUND_HALF_UP` |
-| `Decimal::ROUND_CEIL` | `7` |
-| `Decimal::ROUND_FLOOR` | `8` |
-| `Decimal::ROUND_HALF_DOWN` | `9` |
-| `Decimal::ROUND_HALF_EVEN` | `10` |
-| `Decimal::ROUND_HALF_ODD` | `11` |
-| `Decimal::ROUND_DOWN` | `12`, towards zero |
-| `Decimal::ROUND_UP` | `13`, away from zero |
-| `Decimal::MAX_EXPONENT` | `10000`, limit for scientific notation input |
+| Constant | Value | Native `RoundingMode` |
+| --- | --- | --- |
+| `Decimal::ROUND_HALF_UP` | `1` | `HalfAwayFromZero` |
+| `Decimal::ROUND_CEIL` | `7` | `PositiveInfinity` |
+| `Decimal::ROUND_FLOOR` | `8` | `NegativeInfinity` |
+| `Decimal::ROUND_HALF_DOWN` | `9` | `HalfTowardsZero` |
+| `Decimal::ROUND_HALF_EVEN` | `10` | `HalfEven` |
+| `Decimal::ROUND_HALF_ODD` | `11` | `HalfOdd` |
+| `Decimal::ROUND_DOWN` | `12` | `TowardsZero` |
+| `Decimal::ROUND_UP` | `13` | `AwayFromZero` |
+| `Decimal::ROUND_UNNECESSARY` | `14` | none |
+
+`Decimal::MAX_EXPONENT` is `10000`, the limit for scientific notation input.
See [Rounding](../guide/rounding).
@@ -98,7 +104,6 @@ See [Comparison and checks](../guide/comparison).
| `isEven(): bool` | Whole number divisible by two |
| `isOdd(): bool` | Whole number not divisible by two |
| `isBigInteger(): bool` | Integral part does not fit a PHP integer |
-| `isBigDecimal(): bool` | `toFloat()` would refuse the value |
| `sign(): int` | `-1`, `0` or `1` |
| `scale(): int` | Number of decimal places |
@@ -107,8 +112,8 @@ See [Comparison and checks](../guide/comparison).
| Method | Description |
| --- | --- |
| `toString(): string` | Exact decimal string, also used for `(string)` casts |
-| `toInt(): int` | Integer without decimals. Throws `TypeError` when out of range. |
-| `toFloat(): float` | Float approximation. Throws `TypeError` for big decimals. |
+| `toInt(): int` | Integer without decimals. Throws `OverflowException` when out of range. |
+| `toFloat(): float` | Nearest float. Throws `OverflowException` beyond the float range. |
| `toScientific(): string` | Scientific notation such as `-2.2345e1` |
| `jsonSerialize(): string` | Same as `toString()` |
@@ -116,15 +121,23 @@ See [Conversion](../guide/conversion).
## Exceptions
-| Situation | Exception |
-| --- | --- |
-| Input is not numeric, or a float is `INF` or `NAN` | `InvalidArgumentException` |
-| Exponent beyond `Decimal::MAX_EXPONENT` | `InvalidArgumentException` |
-| Negative scale in the constructor, `withScale()` or `truncate()` | `InvalidArgumentException` |
-| Unknown mode in `round()` or `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()`, `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 |
+All exceptions are in `PhpCollective\DecimalObject\Exception` and implement `DecimalException`, so one `catch` covers the library. Each also extends the SPL type listed, which keeps existing `catch` blocks for those working.
+
+| Exception | Extends | Thrown when |
+| --- | --- | --- |
+| `InvalidNumberException` | `\InvalidArgumentException` | Input is not a number, or its exponent is beyond `MAX_EXPONENT` |
+| `InvalidArgumentException` | `\InvalidArgumentException` | A float is passed; negative scale; unknown rounding mode; exponent in `pow()` that is fractional or beyond `MAX_EXPONENT`; empty list in `average()`, `min()`, `max()`; invalid ratios in `allocate()`; `clamp()` with `$min` above `$max`; step of zero or below |
+| `RoundingNecessaryException` | `\RuntimeException` | A result or input does not fit the scale and no rounding mode was given |
+| `DivisionByZeroException` | `\DivisionByZeroError` | `divide()`, `divideWithRemainder()` or `mod()` by zero; zero raised to a negative power |
+| `NegativeNumberException` | `\DomainException` | `sqrt()` of a negative value |
+| `OverflowException` | `\OverflowException` | `toInt()` or `toFloat()` on a value that does not fit |
+
+```php
+use PhpCollective\DecimalObject\Exception\DecimalException;
+
+try {
+ $rate = Decimal::create($input)->divide($total, 4);
+} catch (DecimalException $e) {
+ // bad input, division by zero, or a quotient that needs rounding
+}
+```
diff --git a/docs/reference/upgrading.md b/docs/reference/upgrading.md
index a63f44f..ec1041e 100644
--- a/docs/reference/upgrading.md
+++ b/docs/reference/upgrading.md
@@ -2,26 +2,102 @@
## From 1.x to 2.0
-2.0 applies the corrections that change results or output for ordinary input. Review each row against your code.
+2.0 has one rule behind most of its changes: the library never drops a digit unless you tell it how. Calls that used to shorten a value without notice now either return the exact result or throw.
+
+### Floats are rejected
+
+A float passed to the constructor, `create()` or any arithmetic method throws an `InvalidArgumentException`. 1.x accepted floats and cut them to 14 significant digits.
+
+- Pass strings: `Decimal::create('19.90')`.
+- Where a float is unavoidable, use `Decimal::createFromFloat($float)` and round the result to the scale you expect.
+
+### Division, roots and powers need a rounding mode
+
+| Call | 1.x | 2.0 |
+| --- | --- | --- |
+| `Decimal::create('10')->divide('3', 4)` | `3.3333` | `RoundingNecessaryException` |
+| `Decimal::create('10')->divide('4', 2)` | `2.50` | `2.50` |
+| `Decimal::create('18')->sqrt()` | `4` | `RoundingNecessaryException` |
+| `Decimal::create('64')->sqrt()` | `8` | `8` |
+| `Decimal::create('1.5')->pow(2)` | `2.2` | `2.25` |
+| `Decimal::average(['1', '2', '4'], 2)` | `2.33` | `RoundingNecessaryException` |
+
+Add `Decimal::ROUND_DOWN` as last argument to keep the 1.x result, or pick the mode you meant, for example `Decimal::ROUND_HALF_UP`. Results that fit the scale need no change.
+
+`pow()` with a positive exponent now returns the exact result instead of cutting it to the scale of the base.
+
+### A scale argument no longer cuts off
+
+| Call | 1.x | 2.0 |
+| --- | --- | --- |
+| `Decimal::create('0.129', 2)` | `0.12` | `RoundingNecessaryException` |
+| `Decimal::create('0.120', 2)` | `0.12` | `0.12` |
+| `Decimal::create('1.5', 0)` | `1.5` | `RoundingNecessaryException` |
+| `Decimal::create('19.99')->multiply('0.19', 2)` | `3.79` | `RoundingNecessaryException` |
+| `Decimal::create('1.55')->multiply('2.00', 2)` | `3.10` | `3.10` |
+
+`add()`, `subtract()`, `multiply()` and `mod()` take the rounding mode as third argument. For a value, round explicitly: `Decimal::create('0.129')->withScale(2, Decimal::ROUND_DOWN)`.
+
+The `$strict` argument of the constructor and of `create()` is removed. Its check is now always on.
+
+### The string shows the scale
+
+| Call | 1.x | 2.0 |
+| --- | --- | --- |
+| `(string)Decimal::create('0.1', 4)` | `0.1` | `0.1000` |
+| `(string)Decimal::create('1e3', 2)` | `1000` | `1000.00` |
+
+Stored and serialized values gain trailing zeros where a larger scale was declared. Call `trim()` if you need the short form.
+
+### Rounding
| Call | 1.x | 2.0 |
| --- | --- | --- |
-| `new Decimal('1.5', 0)` | `1.5` | `1`, throws in strict mode |
| `round(0, PHP_ROUND_HALF_EVEN)` | rounds half up | `InvalidArgumentException` |
| `Decimal::create('1.25')->round(1, Decimal::ROUND_FLOOR)` | `1` | `1.2` |
+| `Decimal::create('15')->round(-1, Decimal::ROUND_FLOOR)` | `InvalidArgumentException` | `10` |
+
+Replace `PHP_ROUND_*` constants with the `Decimal::ROUND_*` ones, or with the native `RoundingMode` enum on PHP 8.4+. `round()` with `ROUND_FLOOR` or `ROUND_CEIL` and a scale above zero used to return a whole number; call `floor()` or `ceil()` if that is what you want.
+
+### Modulo
+
+| Call | 1.x | 2.0 |
+| --- | --- | --- |
| `Decimal::create('10')->mod('0.3')` | `0` | `0.1` |
| `Decimal::create('10')->mod('3.00')` | `1` | `1.00` |
-| `new Decimal('1.0e-5')` | scale 5 | scale 6 |
-| `new Decimal('1e3', 2)` | `1000` | `1000.00` |
-| `Decimal::create('5')->toScientific()` | `5.e0` | `5e0` |
-What to do:
+The result takes the larger scale of both operands.
+
+### Scientific notation
+
+A zero written in a string mantissa counts towards the scale: `'1.0e-5'` has a scale of 6. `toScientific()` no longer prints a decimal point without decimals: `5e0` where 1.x printed `5.e0`.
+
+### Exceptions
+
+The library throws its own exceptions from `PhpCollective\DecimalObject\Exception`, all implementing `DecimalException`. Most extend the type thrown before, so existing `catch` blocks keep working. These do not:
+
+| Situation | 1.x | 2.0 |
+| --- | --- | --- |
+| `toInt()` out of range | `TypeError` | `OverflowException` |
+| `sqrt()` of a negative value | `ValueError` | `NegativeNumberException` |
+| `pow()` with a fractional exponent | `ValueError` | `InvalidArgumentException` |
+| Digits lost against a scale in strict mode | `InvalidArgumentException` | `RoundingNecessaryException` |
+
+See the [exception table](./api#exceptions).
+
+### Conversion
+
+`toFloat()` returns the nearest float for any value in the float range. It used to throw for values with long fractions.
+
+### Removed
+
+- `greatherThanOrEquals()`, the misspelled alias. Use `greaterThanOrEquals()`.
+- `isBigDecimal()`. `toFloat()` no longer depends on it.
+- The `$strict` argument.
+
+### Subclasses
-- **Scale of 0**: a scale of `0` now cuts off decimals like any other scale. Pass `null` when you mean "no scale".
-- **Rounding modes**: replace `PHP_ROUND_*` constants with the `Decimal::ROUND_*` ones. `round()` with `ROUND_FLOOR` or `ROUND_CEIL` and a scale above zero used to return a whole number; call `floor()` or `ceil()` if that is what you want.
-- **Modulo**: the result takes the larger scale of both operands. Pass the scale as second argument to keep the old one.
-- **Scientific notation**: a zero written in a string mantissa counts towards the scale, and `toScientific()` no longer prints a decimal point without decimals.
-- **Removed**: the misspelled `greatherThanOrEquals()`. Use `greaterThanOrEquals()`.
+`setScale()` lost its `$strict` parameter and `resultScale()` is gone. Serialization uses `__serialize()` and stores the decimal string; data written by 1.x is still read.
## From 1.3 to 1.4
diff --git a/phpcs.xml b/phpcs.xml
index 1cf35c6..68efb1d 100644
--- a/phpcs.xml
+++ b/phpcs.xml
@@ -9,6 +9,7 @@
src/tests/
+ tests/stubs/
diff --git a/phpstan.neon b/phpstan.neon
index aa161e6..43b65a8 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -2,6 +2,8 @@ parameters:
level: 8
paths:
- src/
+ scanFiles:
+ - tests/stubs/RoundingMode.php
ignoreErrors:
- identifier: missingType.iterableValue
- '#Unsafe usage of new static\(\).#'
diff --git a/src/Decimal.php b/src/Decimal.php
index 51aa772..d9d2a9b 100644
--- a/src/Decimal.php
+++ b/src/Decimal.php
@@ -9,12 +9,24 @@
namespace PhpCollective\DecimalObject;
-use DivisionByZeroError;
-use InvalidArgumentException;
use JsonSerializable;
+use PhpCollective\DecimalObject\Exception\DivisionByZeroException;
+use PhpCollective\DecimalObject\Exception\InvalidArgumentException;
+use PhpCollective\DecimalObject\Exception\InvalidNumberException;
+use PhpCollective\DecimalObject\Exception\NegativeNumberException;
+use PhpCollective\DecimalObject\Exception\OverflowException;
+use PhpCollective\DecimalObject\Exception\RoundingNecessaryException;
+use RoundingMode;
use Stringable;
-use TypeError;
+/**
+ * Immutable decimal number with a fixed number of decimal places.
+ *
+ * Values are given as strings, integers or other Decimal objects. Every method that takes a
+ * value lists `float` in its type only to reject it with an exception: without the type, PHP
+ * would silently convert a float to an integer for callers that do not use strict types.
+ * Use createFromFloat() to convert a float on purpose.
+ */
class Decimal implements JsonSerializable, Stringable
{
/**
@@ -68,6 +80,13 @@ class Decimal implements JsonSerializable, Stringable
*/
public const ROUND_UP = 13;
+ /**
+ * Refuses to round: throws if the result does not fit the scale exactly.
+ *
+ * @var int
+ */
+ public const ROUND_UNNECESSARY = 14;
+
/**
* Largest exponent accepted in scientific notation, in either direction.
*
@@ -101,17 +120,17 @@ class Decimal implements JsonSerializable, Stringable
protected int $scale = 0;
/**
- * @param object|string|float|int $value
- * @param int|null $scale Decimal places in the result. Omit to enable auto-detection.
- * @param bool $strict If scale should be strictly checked to avoid accidental precision loss.
+ * @param object|string|float|int $value A float is rejected, see the class description.
+ * @param int|null $scale Decimal places. Omit to take them from the value. Zeros are added
+ * to reach a larger scale; a smaller one is only accepted if no digit other than zero is lost.
*/
- public function __construct(object|string|float|int $value, ?int $scale = null, bool $strict = false)
+ public function __construct(object|string|float|int $value, ?int $scale = null)
{
$value = $this->parseValue($value);
$value = $this->normalizeValue($value);
$this->setValue($value, $scale);
- $this->setScale($scale, $strict);
+ $this->setScale($scale);
$this->normalizeZero();
}
@@ -126,31 +145,25 @@ public function scale(): int
/**
* @param object|string|float|int $value
*
- * @throws \InvalidArgumentException
+ * @throws \PhpCollective\DecimalObject\Exception\InvalidNumberException
+ * @throws \PhpCollective\DecimalObject\Exception\InvalidArgumentException For a float.
*
* @return string
*/
protected function parseValue(object|string|float|int $value): string
{
- if (!(is_scalar($value) || $value instanceof Stringable)) {
- throw new InvalidArgumentException('Invalid value');
+ if (is_float($value)) {
+ throw new InvalidArgumentException('Floats are not accepted, use Decimal::createFromFloat().');
}
-
- if ($value instanceof Stringable) {
+ if (is_object($value)) {
+ if (!($value instanceof Stringable)) {
+ throw new InvalidNumberException('Invalid value');
+ }
$value = (string)$value;
}
if (is_string($value) && !is_numeric(trim($value))) {
- throw new InvalidArgumentException('Invalid non numeric value');
- }
-
- if (is_float($value)) {
- if (!is_finite($value)) {
- throw new InvalidArgumentException('Invalid non finite value');
- }
-
- // PHP renders large and small floats as `1.0E+25`, that zero is not a significant digit.
- return str_replace('.0E', 'E', (string)$value);
+ throw new InvalidNumberException('Invalid non numeric value');
}
return (string)$value;
@@ -182,17 +195,16 @@ protected function normalizeValue(string $value): string
*
* @param object|string|float|int $value
* @param int|null $scale Decimal places in the result. Omit to enable auto-detection.
- * @param bool $strict If scale should be strictly checked to avoid accidental precision loss.
*
* @return static
*/
- public static function create(object|string|float|int $value, ?int $scale = null, bool $strict = false): static
+ public static function create(object|string|float|int $value, ?int $scale = null): static
{
if ($scale === null && $value instanceof static) {
return clone $value;
}
- return new static($value, $scale, $strict);
+ return new static($value, $scale);
}
/**
@@ -211,7 +223,7 @@ public static function tryCreate(object|string|float|int|null $value, ?int $scal
try {
return static::create($value, $scale);
- } catch (InvalidArgumentException) {
+ } catch (InvalidNumberException) {
return null;
}
}
@@ -219,19 +231,19 @@ 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.
+ * This is the only way to pass a float. The result holds what the float really
+ * stores, including its binary rounding error.
*
* @param float $value
*
- * @throws \InvalidArgumentException If the float is INF or NAN.
+ * @throws \PhpCollective\DecimalObject\Exception\InvalidNumberException 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');
+ throw new InvalidNumberException('Invalid non finite value');
}
$string = '0';
@@ -279,12 +291,13 @@ public static function sum(iterable $values): static
/**
* @param iterable