Passed
Push — master ( c68256...8d64d8 )
by Leszek
51s
created

DecimalMath::productWithBC()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 12
ccs 6
cts 6
cp 1
rs 9.8666
c 0
b 0
f 0
cc 2
nc 2
nop 2
crap 2
1
<?php
2
3
namespace DataValues;
4
5
use InvalidArgumentException;
6
7
/**
8
 * Class for performing basic arithmetic and other transformations
9
 * on DecimalValues.
10
 *
11
 * This uses the bcmath library if available. Otherwise, it falls back on
12
 * using floating point operations.
13
 *
14
 * @note: this is not a genuine decimal arithmetics implementation,
15
 * and should not be used for financial computations, physical simulations, etc.
16
 *
17
 * @see DecimalValue
18
 *
19
 * @since 0.1
20
 *
21
 * @license GPL-2.0+
22
 * @author Daniel Kinzler
23
 */
24
class DecimalMath {
25
26
	/**
27
	 * Whether to use the bcmath library.
28
	 *
29
	 * @var bool
30
	 */
31
	private $useBC;
32
33
	/**
34
	 * @param bool|null $useBC Whether to use the bcmath library. If null,
35
	 *        bcmath will automatically be used if available.
36
	 */
37 224
	public function __construct( $useBC = null ) {
38 224
		if ( $useBC === null ) {
39 200
			$useBC = function_exists( 'bcscale' );
40
		}
41
42 224
		$this->useBC = $useBC;
43 224
	}
44
45
	/**
46
	 * Whether this is using the bcmath library.
47
	 *
48
	 * @return bool
49
	 */
50
	public function getUseBC() {
51
		return $this->useBC;
52
	}
53
54
	/**
55
	 * @param DecimalValue $a
56
	 * @param DecimalValue $b
57
	 *
58
	 * @return DecimalValue
59
	 */
60 28
	public function product( DecimalValue $a, DecimalValue $b ) {
61 28
		if ( $this->useBC ) {
62 16
			return $this->productWithBC( $a, $b );
63
		}
64 12
		return $this->productWithoutBC( $a, $b );
65
	}
66
67
	/**
68
	 * @param DecimalValue $a
69
	 * @param DecimalValue $b
70
	 *
71
	 * @return DecimalValue
72
	 */
73 16
	private function productWithBC( DecimalValue $a, DecimalValue $b ) {
74 16
		$scale = strlen( $a->getFractionalPart() ) + strlen( $b->getFractionalPart() );
75 16
		$product = bcmul( $a->getValue(), $b->getValue(), $scale );
76
77 16
		$sign = $product[0] === '-' ? '' : '+';
78
79
		// (Potentially) round so that the result fits into a DecimalValue
80
		// Note: Product might still be to long if a*b >= 10^126
81 16
		$product = $this->roundDigits( $sign . $product, 126 );
82
83 16
		return new DecimalValue( $product );
84
	}
85
86
	/**
87
	 * @param DecimalValue $a
88
	 * @param DecimalValue $b
89
	 *
90
	 * @return DecimalValue
91
	 */
92 12
	private function productWithoutBC( DecimalValue $a, DecimalValue $b ) {
93 12
		$product = $a->getValueFloat() * $b->getValueFloat();
94
95
		// Append .0 for consistency, if the result is a whole number,
96
		// but $a or $b were specified with decimal places.
97
		if (
98 12
			$product === floor( $product ) &&
99 12
			$a->getFractionalPart() . $b->getFractionalPart() !== ''
100
		) {
101 2
			$product = strval( $product ) . '.0';
102
		}
103
104 12
		return new DecimalValue( $product );
105
	}
106
107
	/**
108
	 * @param DecimalValue $a
109
	 * @param DecimalValue $b
110
	 *
111
	 * @return DecimalValue
112
	 */
113 9
	public function sum( DecimalValue $a, DecimalValue $b ) {
114 9
		if ( $this->useBC ) {
115 9
			$scale = max( strlen( $a->getFractionalPart() ), strlen( $b->getFractionalPart() ) );
116 9
			$sum = bcadd( $a->getValue(), $b->getValue(), $scale );
117
		} else {
118
			$sum = $a->getValueFloat() + $b->getValueFloat();
119
		}
120
121 9
		return new DecimalValue( $sum );
122
	}
123
124
	/**
125
	 * @param DecimalValue $a
126
	 * @param DecimalValue $b
127
	 *
128
	 * @return DecimalValue
129
	 */
130 5
	public function min( DecimalValue $a, DecimalValue $b ) {
131 5
		if ( $this->useBC ) {
132 5
			$scale = max( strlen( $a->getFractionalPart() ), strlen( $b->getFractionalPart() ) );
133 5
			$comp = bccomp( $a->getValue(), $b->getValue(), $scale );
134 5
			$min = $comp > 0 ? $b : $a;
135
		} else {
136
			$min = min( $a->getValueFloat(), $b->getValueFloat() );
137
			$min = new DecimalValue( $min );
138
		}
139
140 5
		return $min;
141
	}
142
143
	/**
144
	 * @param DecimalValue $a
145
	 * @param DecimalValue $b
146
	 *
147
	 * @return DecimalValue
148
	 */
149 5
	public function max( DecimalValue $a, DecimalValue $b ) {
150 5
		if ( $this->useBC ) {
151 5
			$scale = max( strlen( $a->getFractionalPart() ), strlen( $b->getFractionalPart() ) );
152 5
			$comp = bccomp( $a->getValue(), $b->getValue(), $scale );
153 5
			$max = $comp > 0 ? $a : $b;
154
		} else {
155
			$max = max( $a->getValueFloat(), $b->getValueFloat() );
156
			$max = new DecimalValue( $max );
157
		}
158
159 5
		return $max;
160
	}
161
162
	/**
163
	 * Returns the given value, with any insignificant digits removed or zeroed.
164
	 *
165
	 * Rounding is applied  using the "round half away from zero" rule (that is, +0.5 is
166
	 * rounded to +1 and -0.5 is rounded to -1).
167
	 *
168
	 * @since 0.1
169
	 *
170
	 * @param DecimalValue $decimal
171
	 * @param int $significantDigits The number of digits to retain, counting the decimal point,
172
	 *        but not counting the leading sign.
173
	 *
174
	 * @throws InvalidArgumentException
175
	 * @return DecimalValue
176
	 */
177 105
	public function roundToDigit( DecimalValue $decimal, $significantDigits ) {
178 105
		$value = $decimal->getValue();
179 105
		$rounded = $this->roundDigits( $value, $significantDigits );
180 105
		return new DecimalValue( $rounded );
181
	}
182
183
	/**
184
	 * Returns the given value, with any insignificant digits removed or zeroed.
185
	 *
186
	 * Rounding is applied  using the "round half away from zero" rule (that is, +0.5 is
187
	 * rounded to +1 and -0.5 is rounded to -1).
188
	 *
189
	 * @since 0.1
190
	 *
191
	 * @param DecimalValue $decimal
192
	 * @param int $significantExponent 	 The exponent of the last significant digit,
193
	 *        e.g. -1 for "keep the first digit after the decimal point", or 2 for
194
	 *        "zero the last two digits before the decimal point".
195
	 *
196
	 * @throws InvalidArgumentException
197
	 * @return DecimalValue
198
	 */
199 47
	public function roundToExponent( DecimalValue $decimal, $significantExponent ) {
200
		//NOTE: the number of digits to keep (without the leading sign)
201
		//      is the same as the exponent's offset (with the leaqding sign).
202 47
		$digits = $this->getPositionForExponent( $significantExponent, $decimal );
203 47
		return $this->roundToDigit( $decimal, $digits );
204
	}
205
206
	/**
207
	 * Returns the (zero based) position for the given exponent in
208
	 * the given decimal string, counting the decimal point and the leading sign.
209
	 *
210
	 * @example: the position of exponent 0 in "+10.03" is 2.
211
	 * @example: the position of exponent 1 in "+210.03" is 2.
212
	 * @example: the position of exponent -2 in "+1.037" is 4.
213
	 *
214
	 * @param int $exponent
215
	 * @param DecimalValue $decimal
216
	 *
217
	 * @return int
218
	 */
219 53
	public function getPositionForExponent( $exponent, DecimalValue $decimal ) {
220 53
		$decimal = $decimal->getValue();
221
222 53
		$pointPos = strpos( $decimal, '.' );
223 53
		if ( $pointPos === false ) {
224 16
			$pointPos = strlen( $decimal );
225
		}
226
227
		// account for leading sign
228 53
		$pointPos--;
229
230 53
		if ( $exponent < 0 ) {
231
			// account for decimal point
232 19
			$position = $pointPos + 1 - $exponent;
233
		} else {
234
			// make sure we don't remove more digits than are there
235 34
			$position = max( 0, $pointPos - $exponent );
236
		}
237
238 53
		return $position;
239
	}
240
241
	/**
242
	 * Returns the given value, with any insignificant digits removed or zeroed.
243
	 *
244
	 * Rounding is applied using the "round half away from zero" rule (that is, +0.5 is
245
	 * rounded to +1 and -0.5 is rounded to -1).
246
	 *
247
	 * @see round()
248
	 *
249
	 * @param string $value
250
	 * @param int $significantDigits
251
	 *
252
	 * @throws InvalidArgumentException if $significantDigits is smaller than 0
253
	 * @return string
254
	 */
255 121
	private function roundDigits( $value, $significantDigits ) {
256 121
		if ( !is_int( $significantDigits ) || $significantDigits < 0 ) {
257
			throw new InvalidArgumentException( '$significantDigits must be a non-negative integer' );
258
		}
259
260
		// keeping no digits results in zero.
261 121
		if ( $significantDigits === 0 ) {
262 7
			return '+0';
263
		}
264
265 114
		$len = strlen( $value );
266
267
		// keeping all digits means no rounding
268 114
		if ( $significantDigits >= $len - 1 ) {
269 51
			return $value;
270
		}
271
272
		// whether the last character is already part of the integer part of the decimal value
273 63
		$i = min( $significantDigits + 1, $len ); // account for the sign
274 63
		$ch = $i < $len ? $value[$i] : '0';
275
276 63
		if ( $ch === '.' ) {
277
			// NOTE: we expect the input to be well formed, so it cannot end with a '.'
278 27
			$i++;
279 27
			$ch = $i < $len ? $value[$i] : '0';
280
		}
281
282
		// split in significant and insignificant part
283 63
		$rounded = substr( $value, 0, $i );
284
285 63
		if ( strpos( $rounded, '.' ) === false ) {
286 13
			$suffix = substr( $value, $i );
287
288
			// strip insignificant digits after the decimal point
289 13
			$ppos = strpos( $suffix, '.' );
290 13
			if ( $ppos !== false ) {
291 3
				$suffix = substr( $suffix, 0, $ppos );
292
			}
293
294
			// zero out insignificant digits
295 13
			$suffix = strtr( $suffix, '123456789', '000000000' );
296
		} else {
297
			// decimal point is in $rounded, so $suffix is insignificant
298 50
			$suffix = '';
299
		}
300
301 63
		if ( $ch >= '5' ) {
302 27
			$rounded = $this->bumpDigits( $rounded );
303
		}
304
305 63
		$rounded .= $suffix;
306
307 63
		if ( $significantDigits > strlen( $rounded ) - 1 ) {
308
			if ( strpos( $rounded, '.' ) !== false ) {
309
				$rounded = str_pad( $rounded, $significantDigits + 1, '0', STR_PAD_RIGHT );
310
			}
311
		}
312
313
		// strip trailing decimal point
314 63
		$rounded = rtrim( $rounded, '.' );
315
316 63
		return $rounded;
317
	}
318
319
	/**
320
	 * Increment the least significant digit by one if it is less than 9, and
321
	 * set it to zero and continue to the next more significant digit if it is 9.
322
	 * Exception: bump( 0 ) == 1;
323
	 *
324
	 * E.g.: bump( 0.2 ) == 0.3, bump( -0.09 ) == -0.10, bump( 9.99 ) == 10.00
325
	 *
326
	 * This is the inverse of @see slump()
327
	 *
328
	 * @since 0.1
329
	 *
330
	 * @param DecimalValue $decimal
331
	 *
332
	 * @return DecimalValue
333
	 */
334 16
	public function bump( DecimalValue $decimal ) {
335 16
		$value = $decimal->getValue();
336 16
		$bumped = $this->bumpDigits( $value );
337 16
		return new DecimalValue( $bumped );
338
	}
339
340
	/**
341
	 * Increment the least significant digit by one if it is less than 9, and
342
	 * set it to zero and continue to the next more significant digit if it is 9.
343
	 *
344
	 * @see bump()
345
	 *
346
	 * @param string $value
347
	 * @return string
348
	 */
349 43
	private function bumpDigits( $value ) {
350 43
		if ( $value === '+0' ) {
351 2
			return '+1';
352
		}
353
354 41
		$bumped = '';
355
356 41
		for ( $i = strlen( $value ) - 1; $i >= 0; $i-- ) {
357 41
			$ch = $value[$i];
358
359 41
			if ( $ch === '.' ) {
360 14
				$bumped = '.' . $bumped;
361 14
				continue;
362 41
			} elseif ( $ch === '9' ) {
363 18
				$bumped = '0' . $bumped;
364 18
				continue;
365 41
			} elseif ( $ch === '+' || $ch === '-' ) {
366 14
				$bumped = $ch . '1' . $bumped;
367 14
				break;
368
			} else {
369 27
				$bumped = chr( ord( $ch ) + 1 ) . $bumped;
370 27
				break;
371
			}
372
		}
373
374 41
		$bumped = substr( $value, 0, $i ) . $bumped;
375 41
		return $bumped;
376
	}
377
378
	/**
379
	 * Decrement the least significant digit by one if it is more than 0, and
380
	 * set it to 9 and continue to the next more significant digit if it is 0.
381
	 * Exception: slump( 0 ) == -1;
382
	 *
383
	 * E.g.: slump( 0.2 ) == 0.1, slump( -0.10 ) == -0.01, slump( 0.0 ) == -1.0
384
	 *
385
	 * This is the inverse of @see bump()
386
	 *
387
	 * @since 0.1
388
	 *
389
	 * @param DecimalValue $decimal
390
	 *
391
	 * @return DecimalValue
392
	 */
393 24
	public function slump( DecimalValue $decimal ) {
394 24
		$value = $decimal->getValue();
395 24
		$slumped = $this->slumpDigits( $value );
396 24
		return new DecimalValue( $slumped );
397
	}
398
399
	/**
400
	 * Decrement the least significant digit by one if it is more than 0, and
401
	 * set it to 9 and continue to the next more significant digit if it is 0.
402
	 *
403
	 * @see slump()
404
	 *
405
	 * @param string $value
406
	 * @return string
407
	 */
408 24
	private function slumpDigits( $value ) {
409 24
		if ( $value === '+0' ) {
410 2
			return '-1';
411
		}
412
413
		// a "precise zero" will become negative
414 22
		if ( preg_match( '/^\+0\.(0*)0$/', $value, $m ) ) {
415 4
			return '-0.' . $m[1] . '1';
416
		}
417
418 18
		$slumped = '';
419
420 18
		for ( $i = strlen( $value ) - 1; $i >= 0; $i-- ) {
421 18
			$ch = substr( $value, $i, 1 );
422
423 18
			if ( $ch === '.' ) {
424 2
				$slumped = '.' . $slumped;
425 2
				continue;
426 18
			} elseif ( $ch === '0' ) {
427 8
				$slumped = '9' . $slumped;
428 8
				continue;
429 18
			} elseif ( $ch === '+' || $ch === '-' ) {
430
				$slumped = '0';
431
				break;
432
			} else {
433 18
				$slumped = chr( ord( $ch ) - 1 ) . $slumped;
434 18
				break;
435
			}
436
		}
437
438
		// preserve prefix
439 18
		$slumped = substr( $value, 0, $i ) . $slumped;
440 18
		$slumped = $this->stripLeadingZeros( $slumped );
441
442 18
		if ( $slumped === '-0' ) {
443 1
			$slumped = '+0';
444
		}
445
446 18
		return $slumped;
447
	}
448
449
	/**
450
	 * @param string $digits
451
	 *
452
	 * @return string
453
	 */
454 40
	private function stripLeadingZeros( $digits ) {
455 40
		$digits = preg_replace( '/^([-+])0+(?=\d)/', '\1', $digits );
456 40
		return $digits;
457
	}
458
459
	/**
460
	 * Shift the decimal point according to the given exponent.
461
	 *
462
	 * @param DecimalValue $decimal
463
	 * @param int $exponent The exponent to apply (digits to shift by). A Positive exponent
464
	 * shifts the decimal point to the right, a negative exponent shifts to the left.
465
	 *
466
	 * @throws InvalidArgumentException
467
	 * @return DecimalValue
468
	 */
469 26
	public function shift( DecimalValue $decimal, $exponent ) {
470 26
		if ( !is_int( $exponent ) ) {
471
			throw new InvalidArgumentException( '$exponent must be an integer' );
472
		}
473
474 26
		if ( $exponent == 0 ) {
475 4
			return $decimal;
476
		}
477
478 22
		$sign = $decimal->getSign();
479 22
		$intPart = $decimal->getIntegerPart();
480 22
		$fractPart = $decimal->getFractionalPart();
481
482 22
		if ( $exponent < 0 ) {
483 14
			$intPart = $this->shiftLeft( $intPart, $exponent );
484
		} else {
485 8
			$fractPart = $this->shiftRight( $fractPart, $exponent );
486
		}
487
488 22
		$digits = $sign . $intPart . $fractPart;
489 22
		$digits = $this->stripLeadingZeros( $digits );
490
491 22
		return new DecimalValue( $digits );
492
	}
493
494
	/**
495
	 * @param string $intPart
496
	 * @param int $exponent must be negative
497
	 *
498
	 * @return string
499
	 */
500 14
	private function shiftLeft( $intPart, $exponent ) {
501
		//note: $exponent is negative!
502 14
		if ( -$exponent < strlen( $intPart ) ) {
503 2
			$intPart = substr( $intPart, 0, $exponent ) . '.' . substr( $intPart, $exponent );
504
		} else {
505 12
			$intPart = '0.' . str_pad( $intPart, -$exponent, '0', STR_PAD_LEFT );
506
		}
507
508 14
		return $intPart;
509
	}
510
511
	/**
512
	 * @param string $fractPart
513
	 * @param int $exponent must be positive
514
	 *
515
	 * @return string
516
	 */
517 8
	private function shiftRight( $fractPart, $exponent ) {
518
		//note: $exponent is positive.
519 8
		if ( $exponent < strlen( $fractPart ) ) {
520
			$fractPart = substr( $fractPart, 0, $exponent ) . '.' . substr( $fractPart, $exponent );
521
		} else {
522 8
			$fractPart = str_pad( $fractPart, $exponent, '0', STR_PAD_RIGHT );
523
		}
524
525 8
		return $fractPart;
526
	}
527
528
}
529