ZeCalculator
is a C++20
library for parsing and computing mathematical expressions and objects.
- Supported math objects
- defined through a simple equation of the type
<object declaration> = <math expression>
- Multi-variable functions e.g.
f(x, y) = cos(x) * sin(y)
- Sequences: e.g.
u(n) = 0 ; 1 ; u(n-1) + u(n-2)
- Global Variables: functions without arguments, e.g.
my_var = f(1, 1)
- Global constants: simple valued e.g.
my_constant = 1.2
- Multi-variable functions e.g.
- (custom) C++ math functions can be added
- 1D Data with arbitrary expressions (not only just numbers, see code example bellow)
- defined through a simple equation of the type
- Handle elegantly wrong math expressions
- No exceptions (only used when the user does something wrong)
- Give meaningful error messages: what went wrong, on what part of the equation
- Has no dependencies for easy packaging
- Fast repetitive evaluation of math objects.
- Know dependencies between objects (which object calls which other objects)
#include <zecalculator/zecalculator.h>
#include <zecalculator/test-utils/print-utils.h>
using namespace zc;
using namespace tl;
using namespace std;
double square(double x) { return x * x; }
int main()
{
rpn::MathWorld world;
// Notes about adding a math object to a math world:
// - Each added object exists only within the math world that creates it
// - Adding a math object returns a DynMathObject reference that is essentially an expected<variant, error>
// with some helper functions.
// - the variant contains all the possible objects: function, sequence, global constant, cpp function
// - the error expresses what went wrong in adding the object / parsing the equation
rpn::DynMathObject& obj1 = world.new_object();
// Assign a one parameter function named "f"
// Note that 'my_constant' is only defined later
// - this function's state will be updated once 'my_constant' is defined
// - (re)defining objects within a math world can potentially modify every other objects
obj1 = "f(x) = x + my_constant + cos(math::pi)";
// We can query the direct dependencies of any object: name, type, and where in the equation
// Note: only Function and Sequence instances with a valid equation return a non-empty set
assert(bool(obj1.direct_dependencies()
== deps::Deps{{"my_constant", {deps::Dep::VARIABLE, {11}}},
{"cos", {deps::Dep::FUNCTION, {25}}},
{"math::pi", {deps::Dep::VARIABLE, {29}}}}));
// the expected should hold an error since 'my_constant' is undefined at this point
assert(not obj1.has_value()
and obj1.error()
== Error::undefined_variable(parsing::tokens::Text{"my_constant", 11},
"f(x) = x + my_constant + cos(math::pi)"));
rpn::DynMathObject& obj2 = world.new_object();
// Assign a global constant called "my_constant" with an initial value of 3.0
obj2 = "my_constant = 3.0";
// now that 'my_constant' is defined, 'obj1' gets modified to properly hold a function
// Note that assigning to an object in the MathWorld may affect any other object
// -> Assigning to objects is NOT thread-safe
assert(obj1.object_type() == zc::FUNCTION);
// We can evaluate 'obj1' with an initializer_list<double>
// note: we could also do it when 'my_constant' was undefined,
// in that case the result would be the same error as above
expected<double, Error> eval = obj1({1.0});
// Notes:
// - We know the expression is correct, otherwise the call `.value()` will throw
// - The error can be recovered with '.error()`
// - To know if the result is correct
// - call `.has_value()`
// - use the `bool()` operator on 'eval'
assert(eval.value() == 3);
// add a single argument function 'g' to the world
world.new_object() = "g(z) = 2*z + my_constant";
// assign a new equation to 'obj1'
// - Now it's the Fibonacci sequence called 'u'
// - Recognized by its "sequence" of expressions
// - The last expressions is the "generic" one that applies for any other index
obj1 = "u(n) = 0 ; 1 ; u(n-1) + u(n-2)";
// should hold a Sequence now
assert(obj1.object_type() == zc::SEQUENCE);
// evaluate function again and get the new value
assert(obj1({10}).value() == 55);
// C++ double(double...) functions can also be registered in a world
auto& obj3 = world.new_object();
obj3.set("square", CppFunction{square});
// Can evaluate an expression directly using the math world
assert(world.evaluate("square(2)").value() == 4.);
// define Data object
// can use numbers or complex expressions for each of its values
// can define a name for the line index, e.g. 'index', so it can be used in its expressions
auto& obj4 = world.new_object();
obj4.set_data("data(index)", {"1.0", "square(2)*index", "u(10)"});
// data objects can be used like regular functions
// to retrieve their values on each index
assert(world.evaluate("data(0)").value() == 1.);
assert(obj4({1}).value() == 4.);
assert(obj4({2}).value() == 55.);
obj4.set_data_point(1, "square(3)+1");
assert(obj4({1}).value() == 10.);
return 0;
}
More examples of what the library can do are in the test folder.
Classes within header files are fully documented.
Overview:
- The entry-point class is MathWorld
rpn::MathWorld mathworld;
- Within the same
MathWorld
instance, objects can "see" and "talk" to each other. - Every instance of
MathWorld
is filled with the usual functions and constants, see builtin.h. - Stores its objects in a container of zc::DynMathObject
- Does not invalidate references to unaffected
zc::DynMathObject
instances it contains when growing/shrinking - When adding an object, the math world returns a zc::DynMathObject&, and that can be used as a "permanent handle"
rpn::DynMathObject& obj = mathworld.new_object();
- Does not invalidate references to unaffected
- Within the same
- DynMathObject acts as a generic math object
- Has a "left hand side" (LHS) that defines its name and the name of its input variables, e.g.
f(x)
(notice the white spaces)- The status of it can be queried with
tl::expected<Ok, zc::Error> name_status() const
- The status of it can be queried with
- Has a "right hand side" (RHS) that defines how the object is compute, and differs among the possible math object types
- The type can be
- Simple double valued constant
- C++ function
- 1D Data object
- Sequence
- Global variable
- The status of it can be queried with
tl::expected<Ok, zc::Error> object_status() const
- The type can be
- The overall status (i.e. valid LHS and RHS) can be queried with
tl::expected<Ok, zc::Error> status() const
bool has_value()
std::optional<zc::Error> error()
to retrieve the error in either the LHS or RHS
- Some extra helper methods
bool holds(ObjectType)
to know what underlying object type is currently set
- Can be assigned, using
operator =
, equations or math objects.- CppFunction
double square(double x) { return x * x; } // ... obj = zc::CppFunction{"square", square};
- Function
// automatic type deduction obj = "f(x) = cos(x)";
- Sequence
// or sequence with first values, the last expression is the generic expression obj = "fibonacci(n) = 0 ; 1 ; fibonacci(n-1) + fibonacci(n-2)"
- Data
// requires using set_data() obj.set_data("data", {"1.0", "square(2)", "u(10)"});
- GlobalConstant
// defined through an equation of type "name = number" obj = "pi = 3.14"; // or assigned directly without the need of parsing obj = 3.14;
- CppFunction
- Can be evaluated
tl::expected<double, zc::Error> res1 = obj({1.0}); tl::expected<double, zc::Error> res2 = obj.evaluate({12.0, 3.0});
- Has a "left hand side" (LHS) that defines its name and the name of its input variables, e.g.
- Error messages when expressions have faulty syntax or semantics are expressed through the zc::Error class:
- If it is known, gives what part of the equation raised the error with the
token
member, of the type zc::tokens::Text - If it is known, gives the type of error.
- If it is known, gives what part of the equation raised the error with the
- Two namespaces are offered, that express the underlying representation of the parsed math objects
zc::fast::
: using the abstract syntax tree representation (AST)zc::rpn::
: using reverse polish notation (RPN) / postfix notation in a flat representation in memory.- Generated from the
fast
representation, but the time taken by the extra step is negligible (see results of the test "AST/FAST/RPN creation speed") - Has faster evaluation
- Generated from the
There is for now one benchmark defined in the tests, called "parametric function benchmark" in the file test/function_test.cpp, that computes the average evaluation time of the function f(x) = 3*cos(t*x) + 2*sin(x/t) + 4
, where t
is a global constant, in ast
vs rpn
vs c++
.
The current results are (AMD Ryzen 5950X, -march=native -O3
compile flags)
g++ 13.2.1
+libstdc++
+ld.bfd 2.41.0
ast
: 270ns ± 5nsrpn
: 135ns ± 5nsc++
: 75ns ± 5ns
clang++ 17.0.6
+libc++
+ld.lld 17.0.6
ast
: 245ns ± 5nsrpn
: 140ns ± 5nsc++
: 75ns ± 5ns
Benchmark code snippet
"parametric function benchmark"_test = []<class StructType>()
{
constexpr auto duration = nanoseconds(500ms);
{
constexpr parsing::Type type = std::is_same_v<StructType, FAST_TEST> ? parsing::Type::FAST : parsing::Type::RPN;
constexpr std::string_view data_type_str_v = std::is_same_v<StructType, FAST_TEST> ? "FAST" : "RPN";
MathWorld<type> world;
auto& t = (world.new_object() = "t = 1");
auto& f = (world.new_object() = "f(x) =3*cos(t*x) + 2*sin(x/t) + 4");
double x = 0;
double res = 0;
size_t iterations =
loop_call_for(duration, [&]{
res += f({x}).value();
x++;
t = x;
});
std::cout << "Avg zc::Function<" << data_type_str_v << "> eval time: "
<< duration_cast<nanoseconds>(duration / iterations).count() << "ns"
<< std::endl;
std::cout << "dummy val: " << res << std::endl;
}
{
double cpp_t = 1;
auto cpp_f = [&](double x) {
return 3*cos(cpp_t*x) + 2*sin(x/cpp_t) + 4;
};
double x = 0;
double res = 0;
size_t iterations =
loop_call_for(duration, [&]{
res += cpp_f(x);
iterations++;
x++;
cpp_t++;
});
std::cout << "Avg C++ function eval time: " << duration_cast<nanoseconds>(duration/iterations).count() << "ns" << std::endl;
std::cout << "dummy val: " << res << std::endl;
}
} | std::tuple<FAST_TEST, RPN_TEST>{};
The project uses the meson build system. Being header-only, it does not have a shared library to build: downstream projects only need the headers
To build tests
git clone https://github.com/AdelKS/ZeCalculator
cd ZeCalculator
meson setup build -D test=true
cd build
meson compile
Once the library is built, all tests can simply be run with
meson test
in the build
folder.
Once the library is built (see above), you can install it by running
meson install
in the build
folder.