7
\$\begingroup\$

/**
 * @module Maths
 */

/**
 * Default number of decimal places for an operation to be accurate to.
 * @type {number}
 */
export const DECIMALS = 5;

/**
 * Number of decimal places for an operation to be accurate to.
 * @constant {Symbol} Decimals
 */
export const Decimals = {
    [1]: Symbol(1),
    [2]: Symbol(2),
    [3]: Symbol(3),
    [4]: Symbol(4),
    [5]: Symbol(5),
    [6]: Symbol(6),
    [7]: Symbol(7),
    [8]: Symbol(8),
    [9]: Symbol(9),
    [10]: Symbol(10),
    [11]: Symbol(11),
    [12]: Symbol(12),
    [13]: Symbol(13),
    [14]: Symbol(14)
};

/**
 * @constant {number} Sign
 * @property {number} POS
 * @property {number} NEG
 */
export const Sign = {
    POS: 1,
    NEG: -1
};

/**
 * Creates a function that accepts arguments of func and either invokes func returning its result, if at least arity number of arguments have been provided, or returns a function that accepts the remaining func arguments, and so on. The arity of func may be specified if func.length is not sufficient.
 * @param {Function} fn
 * @param {...*} args1
 */
const curry = (fn, ...args1) => (...args2) => fn(...args1, ...args2);
/**
 * Perfoms right-to-left function composition. The rightmost function may have any arity; the remaining functions must be unary.
 * @param {...Function} fns
 */
const compose = (...fns) => fns.reduce((f, g) => (...args) => f(g(...args)));
/**
 * Performs left-to-right function composition. The leftmost function may have any arity; the remaining functions must be unary.
 * @param {...Function} fns
 */
const pipe = (...fns) => compose.apply(compose, fns.reverse());

/**
 * Parse single value to integer.
 * @param {number} value
 * @return {number}
 */
const parseIntUnary = value => parseInt(value);
/**
 * Move the decimal point to the right based on given number of decimal places.
 * @param {number} value
 * @param {number} [decimals]
 * @return {number}
 */
const bigify = (value, decimals = DECIMALS) => value * Math.pow(10, decimals);
/**
 * Move the decimal point to the left based on a given number of decimal places.
 * @param {number} value
 * @param {number} [decimals]
 * @return {number}
 */
const smallify = (value, decimals = DECIMALS) => value / Math.pow(10, decimals);

/**
 * Parses result of {@link Maths#bigify} as an integer.
 * @param {number} value
 * @param {number} [decimals]
 * @return {number}
 */
const bigifyToInt = pipe(bigify, parseIntUnary);
/**
 * Parses result of {@link Maths#smallify} as an integer.
 * @param {number} value
 * @param {number} [decimals]
 * @return {number}
 */
const smallifyFromInt = pipe(parseIntUnary, smallify);
/**
 * Returns given value to specified decimal accuracy.
 * @param {number} value
 * @param {number} [decimals]
 * @return {number}
 */
const precise = pipe(bigifyToInt, smallifyFromInt);

/**
 * Finds {@link Maths#Decimals} in arguments and prepends to front of arguments list.
 * @param {function} fn
 * @return {function(number, ...number)}
 */
const decimalify = fn => {
    return (...values) => {
        const symbol = values.find(value => typeof value === 'symbol');
        const decimals = Object.keys(Decimals).find(key => Decimals[key] === symbol);
        values = values.filter(value => typeof value !== 'symbol');
        return Reflect.apply(fn, null, [decimals, ...values]);
    };
};

/**
 * Returns sum of given arguments to a specified accuracy.
 * @param {number} [decimals]
 * @param {number} a
 * @param {number} b
 */
export const plus = curry((decimals, a, b) => smallifyFromInt(bigifyToInt(a, decimals) + bigifyToInt(b, decimals), decimals));
/**
 * Maintain decimal accuracy while performing addition operation, correcting for floating point arithmetic errors.
 * One argument returns arg+0
 * Two or more arguments returns arg1+arg2+...^arg(n)
 * @param {Maths#Decimals} [decimals]
 * @param {...number} values
 * @returns {number}
 */
export const add = decimalify((decimals, ...values) => values.reduce((acc, cur) => plus(decimals, acc, cur), 0));
/**
 * Returns difference between given arguments to a specified accuracy.
 * @param {number} [decimals]
 * @param {number} a
 * @param {number} b
 * @returns {number}
 */
export const minus = curry((decimals, a, b) => smallifyFromInt(bigifyToInt(a) - bigifyToInt(b), decimals));
/**
 * Maintain decimal accuracy while performing subtraction operation, correcting for floating point arithmetic errors.
 * One argument returns arg-0
 * Two or more arguments returns arg1-arg2-...^arg(n)
 * @param {Maths#Decimals} [decimals]
 * @param {...number} values
 * @returns {number}
 */
export const subtract = decimalify((decimals, ...values) => values.reduce((acc, cur, i) => i === 0 ? cur : minus(decimals, acc, cur), 0));
/**
 * Returns product of given arguments to a specified accuracy.
 * @param {number} [decimals]
 * @param {number} a
 * @param {number} b
 * @returns {number}
 */
export const times = curry((decimals, a, b) => smallifyFromInt(bigifyToInt(a, decimals) * precise(b, decimals), decimals));
/**
 * Maintain decimal accuracy while performing multiplication operation, correcting for floating point arithmetic errors.
 * One argument returns arg*1
 * Two or more arguments returns arg1*arg2*...^arg(n)
 * @param {Maths#Decimals} [decimals]
 * @param {...number} values
 * @returns {number}
 */
export const multiply = decimalify((decimals, ...values) => values.reduce((acc, cur) => times(decimals, acc, cur), 1));
/**
 * Returns quotient of given arguments to a specified accuracy.
 * @param {number} [decimals]
 * @param {number} a
 * @param {number} b
 * @returns {number}
 */
export const over = curry((decimals, a, b) => smallifyFromInt(bigifyToInt(a, decimals) / precise(b, decimals), decimals));
/**
 * Maintain decimal accuracy while performing division operation, correcting for floating point arithmetic errors.
 * One argument returns arg/1
 * Two or more arguments returns arg1/arg2/...^arg(n)
 * @param {Maths#Decimals} [decimals]
 * @param {...number} values
 * @returns {number}
 */
export const divide = decimalify((decimals, ...values) => values.reduce((acc, cur, i) => i === 0 ? cur : over(decimals, acc, cur), 0));
/**
 * Returns power of given arguments to a specified accuracy.
 * @param {number} [decimals]
 * @param {number} a
 * @param {number} b
 * @returns {number}
 */
export const exp = curry((decimals, a, b) => precise(a ** precise(b, decimals), decimals));
/**
 * Maintain decimal accuracy while performing power operation.
 * One argument returns arg^1 power
 * Two or more arguments returns arg1^arg2...^arg(n) power
 * @param {Maths#Decimals} [decimals]
 * @param {...number} values
 * @returns {number}
 */
export const pow = decimalify((decimals, ...values) => values.reverse().reduce((acc, cur) => exp(decimals, cur, acc), 1));
/**
 * Maintain decimal accuracy while performing arc cosine (inverse cosine) operation.
 * @param {number} value
 * @param {number} [decimals]
 * @returns {number}
 */
export const acos = curry((value, decimals) => precise(Math.acos(precise(value, decimals)), decimals));
/**
 * Maintain decimal accuracy while performing cosine operation.
 * @param {number} value
 * @param {number} [decimals]
 * @returns {number}
 */
export const cos = curry((value, decimals) => precise(Math.cos(precise(value, decimals)), decimals));
/**
 * Maintain decimal accuracy while performing arc sine operation.
 * @param {number} value
 * @param {number} [decimals]
 * @returns {number}
 */
export const asin = curry((value, decimals) => precise(Math.asin(precise(value, decimals)), decimals));
/**
 * Maintain decimal accuracy while performing sine operation.
 * @param {number} value
 * @param {number} [decimals]
 * @returns {number}
 */
export const sin = curry((value, decimals) => precise(Math.sin(precise(value, decimals)), decimals));
/**
 * Maintain decimal accuracy while performing tangent operation.
 * @param {number} value
 * @param {number} [decimals]
 * @returns {number}
 */
export const tan = curry((value, decimals) => precise(Math.tan(precise(value, decimals)), decimals));
/**
 * Maintain decimal accuracy while performing arc tangent (inverse tangent)
 * @param {number} value
 * @param {number} [decimals]
 * @returns {number}
 */
export const atan = curry((value, decimals) => precise(Math.atan(precise(value, decimals)), decimals));
/**
 * Maintain decimal accuracy while performing arc cosine (inverse cosine) operation.
 * @param {number} y
 * @param {number} x
 * @param {number} [decimals]
 * @returns {number}
 */
export const atan2 = curry((y, x, decimals) => precise(Math.atan2(precise(y, decimals), precise(x, decimals)), decimals));

/**
 * Get the additive inverse of given subtrahend.
 * @param {number} value - subtrahend
 * @param {number} [minuend=1] - minuend defaults to 1
 * @param {number} [decimals]
 * @returns {number}
 */
export const inv = curry((value, minuend = 1, decimals) => minus(decimals, minuend, value));
/**
 * Get the square of a given value.
 * @param {number} value
 * @param {number} [decimals]
 * @returns {number}
 */
export const e2 = curry((value, decimals) => exp(decimals, value, 2));
/**
 * Get the cube of a given value.
 * @param {number} value
 * @param {number} [decimals]
 * @returns {number}
 */
export const e3 = curry((value, decimals) => exp(decimals, value, 3));
/**
 * Get the negative of a given value.
 * @param {number} value
 * @param {number} [decimals]
 * @returns {number}
 */
export const neg = curry((value, decimals) => times(decimals, value, -1));
/**
 * Get the change (delta) between two values.
 * @param {number} a
 * @param {number} b
 * @param {number} [decimals]
 * @returns {number}
 */
export const delta = curry((a, b, decimals) => minus(decimals, b, a));

I'd like some feedback on a small math library I put together. I'm trying to accomplish the following goals with it:

  1. Provide a lightweight alternative to large JavaScript math libraries like BigNumber.js and number.js for correcting floating point math errors.

  2. Allow for control of the number of decimal places for each operation and the output.

  3. Allow for better readability and ease when translating complicated equations into code:

    const cbez1 = (a, b, c, d, t) =>
      add(a,
        multiply(neg(3), a, t),
        multiply(3, a, e2(t)),
        multiply(neg(a), e3(t)),
        multiply(3, b, t),
        multiply(neg(6), b, e2(t)),
        multiply(3, b, e3(t)),
        multiply(3, c, e2(t)),
        multiply(neg(3), c, e3(t)),
        multiply(d, e3(t)));
    
      

  4. Each basic operation has a unary function version for composability, and add/subtract/multiply/divide/pow have wrappers that allow for n arguments for grouping and non-binary operations.

  5. For any unary operation you can use composition to lock in a specific level of decimal accuracy by using the first argument, for wrappers you can pass a decimal symbol for any argument Decimals[1] and it will use that number of decimals for that operation.

What I'd like feedback on is the following:

  1. Overall feedback on the code in the library.
  2. Overall feedback on the unit tests.
  3. Is the approach for allowing the decimal places to be defined convenient enough, or should another approach be taken (instantiation of a Class instead of static exports, etc.)?
  4. The library allows for the user to break accuracy by specifying too many decimal places or eating up the mantissa with too large of an exponent, should I control this and add checks?
  5. The library does not handle or help with big numbers, to keep it light weight and narrow in use, is that something that would be useful, or is big number support essential for even lean math libraries?
  6. What other math operations should I include? What other helpers?
  7. Is JSDoc still the preferred spec, or should another one be used?
\$\endgroup\$
3
  • 2
    \$\begingroup\$ Personally I think it looks great, I only don't know why you would like to keep it precise to a certain amount of decimals? Shouldn't that be rather at display time instead of at calculation time? \$\endgroup\$
    – Icepickle
    Aug 21, 2017 at 7:29
  • \$\begingroup\$ This make sure when the floating point error correction occurs the operations are all done on whole numbers. \$\endgroup\$ Aug 21, 2017 at 15:26
  • \$\begingroup\$ I dont get it? Your code in effect does (Math.floor ((numA * (10 ** decimal))) operation Math.floor((numB * (10 ** decimal)))) / (10 ** decimal)` To use your code multiply(1200, divide(1,12)) the result is 99.996 while 1200 * 1 / 12 expresses to 100 using doubles and does it in a fraction of the time. \$\endgroup\$
    – Blindman67
    Nov 22, 2017 at 3:51

1 Answer 1

3
\$\begingroup\$

A review.

This review is from the point of (hypothetically) someone looking at your code as a possible library. To give you the gist, it needs work.

Export a single name.

The first issue would be the "namespace" (not that javascript has namespaces). You export way to much. You should have bundled it all up into something a little more useable such as a single importable instance.

export default (()=>{
     // code
     return {  // the exportable interface
        add(...
        subtract(...
        ... etc
     };
})();

Which, for the user of your product, is a lot simpler. For example:

import fixedMath from "smallMathMod.js"

fixedMath.add(10,10);

Naming

For a library, you should not be creating abbreviations. Users prefer that they do not have to read the reference material. Good naming makes your product easier to use, reduces the learning curve, and makes the customer happy. Happy customers are bankable.

Some name change suggestions

In my very first look over the code I thought exp, e2, and e3 where Math.exp(value), Math.exp(2), and Math.exp(3) as in e for Euler's constant which made sense as you don't want to truncate e. But they turned out to be Math.pow(x,2) and Math.pow(x,3). Maybe square and cube would have been better names, and drop exp altogether as it will be confused with Math.exp

delta not really something you associate with - subtract. Delta is the result of an operation, not the name of an operation.

Also, don't over complicate with duplication, especially when you define two names that have very similar meanings yet have different signatures. I see no benefit in exposing plus, minus, times, and over as they present as ambiguous and confuse the interface.

And neg is an abbreviation. negative or maybe negate. Though I would question the need when the minus sign will do the job just as well.

UpperCase?

The typical Javascript convention on naming is to use camelCase and only capitalize the first character if the object is to be created with the new token. Thus Decimal and Sign should be decimal and sign.

Constants 'const' can be SNAKE_CASE_UPPER or camelCase, but not CapitalCamelCase if it will never be used as new CapitalCamelCase;

parseInt

parseInt should never be used as a substitute for Math.floor.

Inconsitancy

It is not obvious when the decimal symbol should be used and not. And if you use the symbol in the wrong function it will throw a type error when the code code tries to coerce the symbol to a number, for example sin(Math.PI, Deciamls[9]). (yes I know that the manual says that is incorrect, but who reads the manual???)

Reason to use the library.

This is where I am stuck. I can see no situation where someone would want to use the library

Your marketing blurb is:

  1. Provide a lightweight alternative to large Javascript math libraries like BigNumber.js and number.js for correcting floating point math errors.
  2. Allow for control of the number of decimal places for each operation and the output.
  3. Allow for better readability and ease when translating complicated equations into code:

I would say point 2 is the only one that your library conforms to. As for point 3 replacing standard operators with function calls is not adding readability as complex equations can be many lines long.

Point 1 is also suspect. You are not correcting errors; you are attempting to avoid errors by limiting precision. And light weight minus the comments it could be half its weight.

Your questions:


  1. Overall feedback on the code in the library.

See rest of answer.


  1. Overall feedback on the unit tests.

Unit testing is poor as you have covered only safe operations and don't test the behaviour of edge cases (over- and under- precision, response to type coercion, bad arguments, errors, and the kitchen sink).

It does not matter what the response is as long as you know that when you make a change the responses and behaviour are the same (within spec) as it was before the change, because if you change some code and an error is thrown, for example, changes from range to reference, there will be problems for those expecting a range error. If the customer's code crashes in the wild because of an undocumented change in your code, they will be looking for an alternative solution.

You test to assure consistent behaviour for your customers, not just to see if 1 + 1 = 2

Also a very neglected unit test is performance.


  1. Is the approach for allowing the decimal places to be defined convenient enough, or should another approach be taken (instantiation of a Class instead of static exports, etc.)?

See start of answer (i.e. Export a single name). You can use a class if you want but a standard singleton would be best.


  1. The library allows for the user to break accuracy by specifying too many decimal places or eating up the mantissa with too large of an exponent, should I control this and add checks?

That is a big yes as currently the code is not trustworthy and happily spits out results that are wrong. For example, add(Number.MAX_SAFE_INTEGER, -10, 10) should at least come with a warning. It is next to unusable without some way to know that the inputs are in a safe range.

There is also the problem of fractions, you code does not handle them well at all.

You could add a fraction function eg multiply(10,frac(2/3)) with the fraction not being converted to decimal until the result is returned, or optionally the result is returned as a fraction. res = multiply(10,frac(2/3)) returns {numerator: 20, denominator : 3} that can then be converted via a getter console.log(res.decimal); // 6.6667 and add(1,multiply(10,frac(2/3))) returns 7

And while on the topic of fractions - you are rounding incorrectly. divide(2,3) should return 0.6667, not 0.6666.

  1. The library does not handle or help with big numbers, to keep it lightweight and narrow in use, is that something that would be useful, or is big number support essential for even lean math libraries?

Big numbers are useful, not essential and in my view big numbers and your library are not related. Mixing the two will only make them both fatter.


  1. What other math operations should I include? What other helpers?

No comment.


  1. Is JSDoc still the preferred spec, or should another one be used?

No comment.

Functional programing.

Javascript is currently not suited to functional programing (FP), though on the cards Tail Call Optimisation (TCO) seems to be hung up on all browsers ATM (*) and my advice is to avoid the very stack hungry and context churning FP paradigm until TCO is standard on the big three browsers.

Without TCO the style of coding that is common to FP is so painfully slow that it can kill a good library.

Testing your code, add(a,b,c,d) against JS a+b+c+d is 3 orders of magnitude slower, or to put it another way: If I need to crunch a set of numbers that takes 1 second (a long time in computer land) to get a result with a very small rounding error, and I used your library to do the same then that single second becomes 16 minutes and the result may be as many orders of magnitude out as I can drink coffee in 16 minutes. That is a problem, especially when all your library does is some rounding.

(*)(Me thinks too many coders relied on stack overflows to exit loops, so that including TCO will break (by infinite loops) too many pages, and browser agents will not break the web so TCO may be a long time coming)

Last word.

I find that the best way to know if people will find a library useful is if you yourself find that the library is an indispensable part of your coding environment. If you never use it or find a need for it chances are very few other people will as well.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.