Passed
Push — nullBounds ( b3e7e4...f3529c )
by no
03:35
created

DecimalMath::product()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
c 1
b 0
f 0
dl 0
loc 10
rs 9.4285
cc 2
eloc 7
nc 2
nop 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
	public function __construct( $useBC = null ) {
38
		if ( $useBC === null ) {
39
			$useBC = function_exists( 'bcscale' );
40
		}
41
42
		$this->useBC = $useBC;
43
	}
44
45
	/**
46
	 * @param int|float|string $number
47
	 *
48
	 * @return DecimalValue
49
	 */
50
	private function makeDecimalValue( $number ) {
51
52
		if ( is_string( $number ) && $number !== '' ) {
53
			if ( $number[0] !== '-' && $number[0] !== '+' ) {
54
				$number = '+' . $number;
55
			}
56
		}
57
58
		return new DecimalValue( $number );
59
	}
60
61
	/**
62
	 * Whether this is using the bcmath library.
63
	 *
64
	 * @return bool
65
	 */
66
	public function getUseBC() {
67
		return $this->useBC;
68
	}
69
70
	/**
71
	 * Returns the product of the two values.
72
	 *
73
	 * @param DecimalValue $a
74
	 * @param DecimalValue $b
75
	 *
76
	 * @return DecimalValue
77
	 */
78
	public function product( DecimalValue $a, DecimalValue $b ) {
79
		if ( $this->useBC ) {
80
			$scale = strlen( $a->getFractionalPart() ) + strlen( $b->getFractionalPart() );
81
			$product = bcmul( $a->getValue(), $b->getValue(), $scale );
82
		} else {
83
			$product = $a->getValueFloat() * $b->getValueFloat();
84
		}
85
86
		return $this->makeDecimalValue( $product );
87
	}
88
89
	/**
90
	 * Returns the sum of the two values.
91
	 *
92
	 * @param DecimalValue $a
93
	 * @param DecimalValue $b
94
	 *
95
	 * @return DecimalValue
96
	 */
97
	public function sum( DecimalValue $a, DecimalValue $b ) {
98
		if ( $this->useBC ) {
99
			$scale = max( strlen( $a->getFractionalPart() ), strlen( $b->getFractionalPart() ) );
100
			$sum = bcadd( $a->getValue(), $b->getValue(), $scale );
101
		} else {
102
			$sum = $a->getValueFloat() + $b->getValueFloat();
103
		}
104
105
		return $this->makeDecimalValue( $sum );
106
	}
107
108
	/**
109
	 * Returns the minimum of the two values
110
	 *
111
	 * @param DecimalValue $a
112
	 * @param DecimalValue $b
113
	 *
114
	 * @return DecimalValue
115
	 */
116
	public function min( DecimalValue $a, DecimalValue $b ) {
117
118
		if ( $this->useBC ) {
119
			$scale = max( strlen( $a->getFractionalPart() ), strlen( $b->getFractionalPart() ) );
120
			$comp = bccomp( $a->getValue(), $b->getValue(), $scale );
121
			$min = $comp > 0 ? $b : $a;
122
		} else {
123
			$min = min( $a->getValueFloat(), $b->getValueFloat() );
124
			$min = $this->makeDecimalValue( $min );
125
		}
126
127
		return $min;
128
	}
129
130
	/**
131
	 * Returns the maximum of the two values
132
	 *
133
	 * @param DecimalValue $a
134
	 * @param DecimalValue $b
135
	 *
136
	 * @return DecimalValue
137
	 */
138
	public function max( DecimalValue $a, DecimalValue $b ) {
139
140
		if ( $this->useBC ) {
141
			$scale = max( strlen( $a->getFractionalPart() ), strlen( $b->getFractionalPart() ) );
142
			$comp = bccomp( $a->getValue(), $b->getValue(), $scale );
143
			$max = $comp > 0 ? $a : $b;
144
		} else {
145
			$max = max( $a->getValueFloat(), $b->getValueFloat() );
146
			$max = $this->makeDecimalValue( $max );
147
		}
148
149
		return $max;
150
	}
151
152
	/**
153
	 * Returns the given value, with any insignificant digits removed or zeroed.
154
	 *
155
	 * Rounding is applied  using the "round half away from zero" rule (that is, +0.5 is
156
	 * rounded to +1 and -0.5 is rounded to -1).
157
	 *
158
	 * @since 0.1
159
	 *
160
	 * @param DecimalValue $decimal
161
	 * @param int $significantDigits The number of digits to retain, counting the decimal point,
162
	 *        but not counting the leading sign.
163
	 *
164
	 * @throws InvalidArgumentException
165
	 * @return DecimalValue
166
	 */
167
	public function roundToDigit( DecimalValue $decimal, $significantDigits ) {
168
		$value = $decimal->getValue();
169
		$rounded = $this->roundDigits( $value, $significantDigits );
170
		return new DecimalValue( $rounded );
171
	}
172
173
	/**
174
	 * Returns the given value, with any insignificant digits removed or zeroed.
175
	 *
176
	 * Rounding is applied  using the "round half away from zero" rule (that is, +0.5 is
177
	 * rounded to +1 and -0.5 is rounded to -1).
178
	 *
179
	 * @since 0.1
180
	 *
181
	 * @param DecimalValue $decimal
182
	 * @param int $significantExponent 	 The exponent of the last significant digit,
183
	 *        e.g. -1 for "keep the first digit after the decimal point", or 2 for
184
	 *        "zero the last two digits before the decimal point".
185
	 *
186
	 * @throws InvalidArgumentException
187
	 * @return DecimalValue
188
	 */
189
	public function roundToExponent( DecimalValue $decimal, $significantExponent ) {
190
		//NOTE: the number of digits to keep (without the leading sign)
191
		//      is the same as the exponent's offset (with the leaqding sign).
192
		$digits = $this->getPositionForExponent( $significantExponent, $decimal );
193
		return $this->roundToDigit( $decimal, $digits );
194
	}
195
196
	/**
197
	 * Returns the (zero based) position for the given exponent in
198
	 * the given decimal string, counting the decimal point and the leading sign.
199
	 *
200
	 * @example: the position of exponent 0 in "+10.03" is 2.
201
	 * @example: the position of exponent 1 in "+210.03" is 2.
202
	 * @example: the position of exponent -2 in "+1.037" is 4.
203
	 *
204
	 * @param int $exponent
205
	 * @param DecimalValue $decimal
206
	 *
207
	 * @return int
208
	 */
209
	public function getPositionForExponent( $exponent, DecimalValue $decimal ) {
210
		$decimal = $decimal->getValue();
211
212
		$pointPos = strpos( $decimal, '.' );
213
		if ( $pointPos === false ) {
214
			$pointPos = strlen( $decimal );
215
		}
216
217
		// account for leading sign
218
		$pointPos--;
219
220
		if ( $exponent < 0 ) {
221
			// account for decimal point
222
			$position = $pointPos +1 - $exponent;
223
		} else {
224
			// make sure we don't remove more digits than are there
225
			$position = max( 0, $pointPos - $exponent );
226
		}
227
228
		return $position;
229
	}
230
231
	/**
232
	 * Returns the given value, with any insignificant digits removed or zeroed.
233
	 *
234
	 * Rounding is applied using the "round half away from zero" rule (that is, +0.5 is
235
	 * rounded to +1 and -0.5 is rounded to -1).
236
	 *
237
	 * @see round()
238
	 *
239
	 * @param string $value
240
	 * @param int $significantDigits
241
	 *
242
	 * @throws InvalidArgumentException if $significantDigits is smaller than 0
243
	 * @return string
244
	 */
245
	private function roundDigits( $value, $significantDigits ) {
246
		if ( !is_int( $significantDigits ) ) {
247
			throw new InvalidArgumentException( '$significantDigits must be an integer' );
248
		}
249
250
		// keeping no digits results in zero.
251
		if ( $significantDigits === 0 ) {
252
			return '+0';
253
		}
254
255
		if ( $significantDigits < 0 ) {
256
			throw new InvalidArgumentException( '$significantDigits must be larger than zero.' );
257
		}
258
259
		// whether the last character is already part of the integer part of the decimal value
260
		$inIntPart = ( strpos( $value, '.' ) === false );
261
262
		$rounded = '';
263
264
		// Iterate over characters from right to left and build the result back to front.
265
		for ( $i = strlen( $value ) -1; $i > 0 && $i > $significantDigits; $i-- ) {
266
267
			list( $value, $i, $inIntPart, $next ) = $this->roundNextDigit( $value, $i, $inIntPart );
268
269
			$rounded = $next . $rounded;
270
		}
271
272
		// just keep the remainder of the value as is (this includes the sign)
273
		$rounded = substr( $value, 0, $i +1 ) . $rounded;
274
275
		if ( strlen( $rounded ) < $significantDigits + 1 ) {
276
			if ( $inIntPart ) {
277
				$rounded .= '.';
278
			}
279
280
			$rounded = str_pad( $rounded, $significantDigits+1, '0', STR_PAD_RIGHT );
281
		}
282
283
		// strip trailing decimal point
284
		$rounded = rtrim( $rounded, '.' );
285
286
		return $rounded;
287
	}
288
289
	/**
290
	 * Extracts the next character to add to the result of a rounding run:
291
	 * $value[$] will be examined and processed in order to determine the next
292
	 * character to prepend to the result (returned in the $nextCharacter field).
293
	 *
294
	 * Updated values for the parameters are returned as well as the next
295
	 * character.
296
	 *
297
	 * @param string $value
298
	 * @param int $i
299
	 * @param bool $inIntPart
300
	 *
301
	 * @return array ( $value, $i, $inIntPart, $nextCharacter )
302
	 */
303
	private function roundNextDigit( $value, $i, $inIntPart ) {
304
		// next digit
305
		$ch = $value[$i];
306
307
		if ( $ch === '.' ) {
308
			// just transition from the fractional to the integer part
309
			$inIntPart = true;
310
			$nextCharacter = '.';
311
		} else {
312
			if ( $inIntPart ) {
313
				// in the integer part, zero out insignificant digits
314
				$nextCharacter = '0';
315
			} else {
316
				// in the fractional part, strip insignificant digits
317
				$nextCharacter = '';
318
			}
319
320
			if ( ord( $ch ) >= ord( '5' ) ) {
321
				// when stripping a character >= 5, bump up the next digit to the left.
322
				list( $value, $i, $inIntPart ) = $this->bumpDigitsForRounding( $value, $i, $inIntPart );
323
			}
324
		}
325
326
		return array( $value, $i, $inIntPart, $nextCharacter );
327
	}
328
329
	/**
330
	 * Bumps the last digit of a value that is being processed for rounding while taking
331
	 * care of edge cases and updating the state of the rounding process.
332
	 *
333
	 * - $value is truncated to $i digits, so we can safely increment (bump) the last digit.
334
	 * - if the last character of $value is '.', it's trimmed (and $inIntPart is set to true)
335
	 *   to handle the transition from the fractional to the integer part of $value.
336
	 * - the last digit of $value is bumped using bumpDigits() - this is where the magic happens.
337
	 * - $i is set to strln( $value ) to make the index consistent in case a trailing decimal
338
	 *   point got removed.
339
	 *
340
	 * Updated values for the parameters are returned.
341
	 * Note: when returning, $i is always one greater than the greatest valid index in $value.
342
	 *
343
	 * @param string $value
344
	 * @param int $i
345
	 * @param bool $inIntPart
346
	 *
347
	 * @return array ( $value, $i, $inIntPart, $next )
348
	 */
349
	private function bumpDigitsForRounding( $value, $i, $inIntPart ) {
350
		$remaining = substr( $value, 0, $i );
351
352
		// If there's a '.' at the end, strip it and note that we are in the
353
		// integer part of $value now.
354
		if ( $remaining[ strlen( $remaining ) -1 ] === '.' ) {
355
			$remaining = rtrim( $remaining, '.' );
356
			$inIntPart = true;
357
		}
358
359
		// Rounding may add digits, adjust $i for that.
360
		$value = $this->bumpDigits( $remaining );
361
		$i = strlen( $value );
362
363
		return array( $value, $i, $inIntPart );
364
	}
365
366
	/**
367
	 * Increment the least significant digit by one if it is less than 9, and
368
	 * set it to zero and continue to the next more significant digit if it is 9.
369
	 * Exception: bump( 0 ) == 1;
370
	 *
371
	 * E.g.: bump( 0.2 ) == 0.3, bump( -0.09 ) == -0.10, bump( 9.99 ) == 10.00
372
	 *
373
	 * This is the inverse of @see slump()
374
	 *
375
	 * @since 0.1
376
	 *
377
	 * @param DecimalValue $decimal
378
	 *
379
	 * @return DecimalValue
380
	 */
381
	public function bump( DecimalValue $decimal ) {
382
		$value = $decimal->getValue();
383
		$bumped = $this->bumpDigits( $value );
384
		return new DecimalValue( $bumped );
385
	}
386
387
	/**
388
	 * Increment the least significant digit by one if it is less than 9, and
389
	 * set it to zero and continue to the next more significant digit if it is 9.
390
	 *
391
	 * @see bump()
392
	 *
393
	 * @param string $value
394
	 * @return string
395
	 */
396
	private function bumpDigits( $value ) {
397
		if ( $value === '+0' ) {
398
			return '+1';
399
		}
400
401
		$bumped = '';
402
403
		for ( $i = strlen( $value ) -1; $i >= 0; $i-- ) {
404
			$ch = $value[$i];
405
406
			if ( $ch === '.' ) {
407
				$bumped = '.' . $bumped;
408
				continue;
409
			} elseif ( $ch === '9' ) {
410
				$bumped = '0' . $bumped;
411
				continue;
412
			} elseif ( $ch === '+' || $ch === '-' ) {
413
				$bumped = $ch . '1' . $bumped;
414
				break;
415
			} else {
416
				$bumped =  chr( ord( $ch ) + 1 ) . $bumped;
417
				break;
418
			}
419
		}
420
421
		$bumped = substr( $value, 0, $i ) . $bumped;
422
		return $bumped;
423
	}
424
425
	/**
426
	 * Decrement the least significant digit by one if it is more than 0, and
427
	 * set it to 9 and continue to the next more significant digit if it is 0.
428
	 * Exception: slump( 0 ) == -1;
429
	 *
430
	 * E.g.: slump( 0.2 ) == 0.1, slump( -0.10 ) == -0.01, slump( 0.0 ) == -1.0
431
	 *
432
	 * This is the inverse of @see bump()
433
	 *
434
	 * @since 0.1
435
	 *
436
	 * @param DecimalValue $decimal
437
	 *
438
	 * @return DecimalValue
439
	 */
440
	public function slump( DecimalValue $decimal ) {
441
		$value = $decimal->getValue();
442
		$slumped = $this->slumpDigits( $value );
443
		return new DecimalValue( $slumped );
444
	}
445
446
	/**
447
	 * Decrement the least significant digit by one if it is more than 0, and
448
	 * set it to 9 and continue to the next more significant digit if it is 0.
449
	 *
450
	 * @see slump()
451
	 *
452
	 * @param string $value
453
	 * @return string
454
	 */
455
	private function slumpDigits( $value ) {
456
		if ( $value === '+0' ) {
457
			return '-1';
458
		}
459
460
		// a "precise zero" will become negative
461
		if ( preg_match( '/^\+0\.(0*)0$/', $value, $m ) ) {
462
			return '-0.' . $m[1] . '1';
463
		}
464
465
		$slumped = '';
466
467
		for ( $i = strlen( $value ) -1; $i >= 0; $i-- ) {
468
			$ch = substr( $value, $i, 1 );
469
470
			if ( $ch === '.' ) {
471
				$slumped = '.' . $slumped;
472
				continue;
473
			} elseif ( $ch === '0' ) {
474
				$slumped = '9' . $slumped;
475
				continue;
476
			} elseif ( $ch === '+' || $ch === '-' ) {
477
				$slumped = '0';
478
				break;
479
			} else {
480
				$slumped =  chr( ord( $ch ) - 1 ) . $slumped;
481
				break;
482
			}
483
		}
484
485
		// preserve prefix
486
		$slumped = substr( $value, 0, $i ) . $slumped;
487
488
		$slumped = $this->stripLeadingZeros( $slumped );
489
490
		if ( $slumped === '-0' ) {
491
			$slumped = '+0';
492
		}
493
494
		return $slumped;
495
	}
496
497
	/**
498
	 * @param string $digits
499
	 *
500
	 * @return string
501
	 */
502
	private function stripLeadingZeros( $digits ) {
503
		$digits = preg_replace( '/^([-+])0+(?=\d)/', '\1', $digits );
504
		return $digits;
505
	}
506
507
	/**
508
	 * Shift the decimal point according to the given exponent.
509
	 *
510
	 * @param DecimalValue $decimal
511
	 * @param int $exponent The exponent to apply (digits to shift by). A Positive exponent
512
	 * shifts the decimal point to the right, a negative exponent shifts to the left.
513
	 *
514
	 * @throws InvalidArgumentException
515
	 * @return DecimalValue
516
	 */
517
	public function shift( DecimalValue $decimal, $exponent ) {
518
		if ( !is_int( $exponent ) ) {
519
			throw new InvalidArgumentException( '$exponent must be an integer' );
520
		}
521
522
		if ( $exponent == 0 ) {
523
			return $decimal;
524
		}
525
526
		$sign = $decimal->getSign();
527
		$intPart = $decimal->getIntegerPart();
528
		$fractPart = $decimal->getFractionalPart();
529
530
		if ( $exponent < 0 ) {
531
			$intPart = $this->shiftLeft( $intPart, $exponent );
532
		} else {
533
			$fractPart = $this->shiftRight( $fractPart, $exponent );
534
		}
535
536
		$digits = $sign . $intPart . $fractPart;
537
		$digits = $this->stripLeadingZeros( $digits );
538
539
		return new DecimalValue( $digits );
540
	}
541
542
	/**
543
	 * @param string $intPart
544
	 * @param int $exponent must be negative
545
	 *
546
	 * @return string
547
	 */
548
	private function shiftLeft( $intPart, $exponent ) {
549
		//note: $exponent is negative!
550
		if ( -$exponent < strlen( $intPart ) ) {
551
			$intPart = substr( $intPart, 0, $exponent ) . '.' . substr( $intPart, $exponent );
552
		} else {
553
			$intPart = '0.' . str_pad( $intPart, -$exponent, '0', STR_PAD_LEFT );
554
		}
555
556
		return $intPart;
557
	}
558
559
	/**
560
	 * @param string $fractPart
561
	 * @param int $exponent must be positive
562
	 *
563
	 * @return string
564
	 */
565
	private function shiftRight( $fractPart, $exponent ) {
566
		//note: $exponent is positive.
567
		if ( $exponent < strlen( $fractPart ) ) {
568
			$fractPart = substr( $fractPart, 0, $exponent ) . '.' . substr( $fractPart, $exponent );
569
		} else {
570
			$fractPart = str_pad( $fractPart, $exponent, '0', STR_PAD_RIGHT );
571
		}
572
573
		return $fractPart;
574
	}
575
576
}
577