![]() |
Expressionist
Header-only C++20 evaluator of algebraic expressions embedded in JSON fields
|

A small, header-only C++20 library that evaluates algebraic expressions embedded in nlohmann::json fields, replacing them in place with their computed values. Variables in an expression are simply other keys of the same JSON tree, so an object can describe a little spreadsheet of interdependent values.
A string is treated as an expression only when it starts with the tag (default "$"); every other string is left untouched. The definition order of the variables does not matter — dependencies are resolved automatically, and circular dependencies or undefined variables are reported as errors with context.
nlohmann::json.$a + b stays 3, $pi/3 becomes a double).+ - * / ^), comparisons (< <= > >= == !=) and logical operators (&& || !), with C-style precedence and short-circuiting.start:stop (integer) or start:stop:step (floating) into a JSON array, endpoint inclusive."expressionist", configurable) lets a subtree disable evaluation entirely, leaving large static fragments untouched.libexpressionist_c) and a ctypes-based Python package, for use from outside C++.ExpressionistException messages carrying the offending field and expression.FetchContent.nlohmann::json (fetched automatically when building this project).Linking the target transitively pulls in nlohmann::json and the include path, so #include <expressionist.hpp> just works.
Copy src/expressionist.hpp into your include path and make sure nlohmann/json.hpp is reachable.
Use produce() when you want a new object and need to keep the original intact:
An engine can also evaluate an object supplied at call time instead of the one it stores. Construct it with just a strategy — the stored object stays empty — and pass the target to evaluate(json&) (mutates it in place) or produce(json) (returns an evaluated copy). Both reuse the engine's configured tag and symbol table, so one engine can be applied to many objects:
Both strategies produce identical results:
EvalMethod::GRAPH builds a dependency graph and evaluates it in topological order (Kahn's algorithm).EvalMethod::RECURSIVE (default) evaluates lazily with memoization and a visiting-set for cycle detection.An optional expressionist executable is provided (target expressionist_cli). It is not built by default; enable it with the EXPRESSIONIST_BUILD_TOOL option, which also pulls in cxxopts via FetchContent:
The tool reads an input JSON, evaluates it, and prints the result to stdout. The input comes either from a positional argument or, if none is given, from stdin.
Output for the first command:
Every class option is exposed as a flag:
| Option | Description | Default |
|---|---|---|
-m, --method arg | Evaluation strategy: graph or recursive | graph |
-t, --tag arg | Prefix that marks a string as an expression | $ |
--disable-key arg | Object key that, set to false, opts its subtree out of evaluation | expressionist |
--indent arg | Spaces used when pretty-printing the output | 2 |
-c, --compact | Emit compact, single-line JSON (overrides --indent) | off |
-h, --help | Print usage and exit | |
-V, --version | Print version and exit |
Exit codes: 0 on success, 1 on a JSON or evaluation error (e.g. a circular dependency or undefined variable, reported with context on stderr), and 2 on a usage error (bad option, unknown method, or no input).
For use from outside C++, a small extern "C" layer (expressionist_c.h / libexpressionist_c) exposes the engine as JSON-string-in, JSON-string-out: no C++ type ever crosses the boundary, so it is consumable from any language with a C FFI. A ctypes-based Python package wraps it.
Both are opt-in and off by default:
EXPRESSIONIST_BUILD_PYTHON implies EXPRESSIONIST_BUILD_C_API and, on install, copies libexpressionist_c and the expressionist Python package into Python3_SITELIB — the site-packages of whichever python3 is first on PATH, so it lands in an active virtualenv automatically. import expressionist then just works, with no PYTHONPATH or LD_LIBRARY_PATH setup:
evaluate() accepts either a JSON-serializable Python object or a JSON string, and always returns a new object without mutating the input — there is no persistent "stored object" across the C boundary, unlike the C++ class. Errors (parse failures, undefined variables, circular dependencies) raise ExpressionistError with the same diagnostic text the C++ API produces. Expressionist is a context manager (with Expressionist() as ex:) for deterministic native cleanup, though __del__ covers it too.
To build just the shared library and header (e.g. for a non-Python FFI consumer) without the Python install step, use -DEXPRESSIONIST_BUILD_C_API=ON instead.
If you already have a shared object you'd rather point the Python wrapper at (a build-tree artifact, a custom install location), set EXPRESSIONIST_C_LIBRARY to its path — it takes precedence over the next-to-__init__.py lookup and the system library search.
| Category | Supported |
|---|---|
| Literals | integers, floats (1.5, 1.5e-3), true, false |
| Arithmetic | + - * / ^ (power, right-associative), unary - |
| Comparison | < <= > >= == != |
| Logical | && \|\| ! (short-circuiting, C-style truthiness) |
| Grouping | ( … ) |
| Sequences | start:stop (integer, step 1) and start:stop:step (floating), endpoint inclusive |
| Constants | pi, e, tau |
| Unary funcs | sin cos tan asin acos atan sinh cosh tanh exp log ln log10 log2 sqrt cbrt abs floor ceil round trunc sign |
| Binary funcs | pow atan2 hypot min max mod |
Precedence (lowest → highest): range :, ||, &&, == !=, < <= > >=, + -, * /, unary - !, ^, primary. Power binds tighter than unary minus, so -2^2 evaluates to -4, and 2^3^2 is 2^(3^2) = 512. Because the range operator sits at the bottom, its parts are ordinary expressions (0 : 2*pi : pi/6).
Result types. + - * keep integer operands integral; / always yields a double; ^ stays integral for non-negative integer exponents; comparisons and logical operators yield booleans; mathematical functions yield doubles; the range operator yields a JSON array.
A range expands into a JSON array. The two-part form takes integer bounds and steps by 1; the three-part form is floating and steps by an explicit amount. Both are inclusive of the endpoint (within rounding), and a step pointing away from the endpoint produces an empty array.
A range must be the whole expression: a sequence cannot take part in scalar arithmetic ("$r + 1" where r is a range is an error), the two-part form requires integer bounds, and the step of the three-part form must be non-zero.
Evaluation recurses into nested objects and arrays. Identifiers resolve lexically: the nearest enclosing object's keys are searched first, then its ancestors. Array elements resolve against their nearest enclosing object. A key shadows a same-named constant.
An object opts itself and everything nested inside it out of evaluation by carrying a member set to the literal boolean false under the disable key (default "expressionist"). The whole subtree is then left byte-for-byte untouched — nothing inside is even inspected for the expression tag — which makes it cheap to mark a large, static JSON fragment as a no-op instead of tagging every nested object individually. The flag itself is left in the output, and its enclosing scope is otherwise unaffected:
Only a literal false disables evaluation — true, a missing key, or any non-boolean value leaves the object evaluated as usual. Setting the flag on the root object turns the whole document into a no-op (this does not apply to a root-level JSON array, which has no keys to carry the flag). The key name is configurable, and setting it to "" turns the mechanism off entirely:
| Member | Purpose |
|---|---|
Expressionist(json, EvalMethod = RECURSIVE) | Construct from a JSON object. |
Expressionist(std::string, EvalMethod = RECURSIVE) | Construct by parsing a JSON string (a const char* literal binds here too). |
Expressionist(EvalMethod = RECURSIVE) | Construct with an empty object, for the by-argument overloads below. |
void evaluate() | Evaluate the stored object in place. |
void evaluate(json&) const | Evaluate the given object in place. |
json produce() const | Evaluate a copy of the stored object and return it. |
json produce(json) const | Evaluate a copy of the given object and return it. |
void setEvalMethod(EvalMethod) / getEvalMethod() | Select / query the strategy. |
void setTag(const std::string&) / tag() | Set / query the expression tag. |
void setDisableKey(const std::string&) / disableKey() | Set / query the opt-out key ("" turns it off). |
const json& object() const | Access the (possibly evaluated) object. |
addConstant(name, value) | Register a constant. |
addUnaryFunction(name, fn) | Register a double(double) function. |
addBinaryFunction(name, fn) | Register a double(double,double) function. |
All failures throw Expressionist::ExpressionistException, whose what() includes the JSON path and expression text.
This also builds example/main.cpp (build/expressionist_example), which prints the PURPOSE.md object evaluated with both strategies.
Published from main at https://pbosetti.github.io/Expressionist/. It covers the public API only: Expressionist::Expressionist, Expressionist::EvalMethod, Expressionist::ExpressionistException and the C ABI in expressionist_c.h — not the internal tokenizer/parser/evaluator.
To build it locally:
The test suite includes a benchmark comparing the two strategies on large interdependent objects and asserting that they return identical results. The GRAPH method does a bit of graph bookkeeping up front, while RECURSIVE resolves lazily; for these workloads they are close, with RECURSIVE slightly ahead.
Indicative figures (Release build, AppleClang, Apple Silicon):
| Workload | GRAPH | RECURSIVE |
|---|---|---|
| Dependency chain, 1000 variables | ~1.5 ms | ~1.1 ms |
| Wide fan-in, 500 leaves → 1 sum | ~0.6 ms | ~0.4 ms |
Run it yourself:
Licensed under the [Apache License, Version 2.0](LICENSE).