Completed
Push — master ( 946fba...596b16 )
by Marius
11:53 queued 04:58
created

DecimalMath::product()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 3.0175

Importance

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