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.
- Header-only, zero runtime dependencies beyond
nlohmann::json. - Hand-written tokenizer + Pratt parser — no third-party expression engine.
- Integer/double/boolean results, preserving integer-ness
(
$a + bstays3,$pi/3becomes a double). - Arithmetic (
+ - * / ^), comparisons (< <= > >= == !=) and logical operators (&& || !), with C-style precedence and short-circuiting. - A range operator that expands
start:stop(integer) orstart:stop:step(floating) into a JSON array, endpoint inclusive. - A library of mathematical functions and constants, extensible at runtime.
- Two interchangeable resolution strategies (topological graph and memoized recursive) selectable per instance.
- Recurses into nested objects and arrays with lexical scoping.
- An opt-out key (default
"expressionist", configurable) lets a subtree disable evaluation entirely, leaving large static fragments untouched. - An optional plain-C ABI (
libexpressionist_c) and actypes-based Python package, for use from outside C++. - Clear
ExpressionistExceptionmessages carrying the offending field and expression. - Consumable via CMake
FetchContent.
- A C++20 compiler (tested with Clang / AppleClang).
- CMake ≥ 3.20 with Ninja recommended.
nlohmann::json(fetched automatically when building this project).
include(FetchContent)
FetchContent_Declare(
Expressionist
GIT_REPOSITORY https://github.com/pbosetti/Expressionist.git
GIT_TAG main
)
FetchContent_MakeAvailable(Expressionist)
target_link_libraries(your_target PRIVATE Expressionist::Expressionist)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.
#include <expressionist.hpp>
#include <iostream>
int main() {
std::string json_text = R"({
"a": 1,
"b": 2,
"c": "$a + b",
"f": "$pi / 3",
"g": "$sin(f) * b"
})";
// Instantiate with a json object
nlohmann::json data = nlohmann::json::parse(json_text);
Expressionist::Expressionist ex1(data);
ex1.evaluate(); // mutate the stored object in place
std::cout << ex1.object().dump(2) << '\n';
// or, equivalently, from a string (a bare string literal works too — it is
// parsed as JSON rather than stored as a string value):
Expressionist::Expressionist ex2(json_text);
ex2.evaluate(); // mutate the stored object in place
std::cout << ex2.object().dump(2) << '\n';
}Use produce() when you want a new object and need to keep the original intact:
Expressionist::Expressionist ex(data);
nlohmann::json result = ex.produce(); // `data` is left unchangedAn 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:
Expressionist::Expressionist ex(EvalMethod::RECURSIVE);
ex.addConstant("g0", 9.80665);
nlohmann::json a = nlohmann::json::parse(R"({"m": 2, "w": "$m * g0"})");
ex.evaluate(a); // mutates `a` in place
nlohmann::json b = ex.produce( // returns a new object
nlohmann::json::parse(R"({"x": 3, "y": "$x ^ 2"})"));using Expressionist::EvalMethod;
Expressionist::Expressionist ex(data, EvalMethod::RECURSIVE);
ex.setEvalMethod(EvalMethod::GRAPH); // switch at any timeBoth strategies produce identical results:
EvalMethod::GRAPHbuilds 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.
ex.setTag("@"); // now "@a + 1" is an expression and "$a + 1" is a literalex.addConstant("g0", 9.80665);
ex.addUnaryFunction("double", [](double x) { return 2.0 * x; });
ex.addBinaryFunction("clampmax", [](double x, double m) { return std::min(x, m); });try {
ex.evaluate();
} catch (const Expressionist::ExpressionistException &e) {
std::cerr << e.what() << '\n';
// e.g. In '/a' ("$missing + 1"): Undefined variable or constant: 'missing'
}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:
cmake -Bbuild -GNinja -DEXPRESSIONIST_BUILD_TOOL=ON
cmake --build buildThe 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.
# JSON passed as an argument
expressionist '{"a":1,"b":2,"c":"$a + b","f":"$pi/3"}'
# JSON piped in on stdin
echo '{"a":1,"c":"$a + 10"}' | expressionist
# read from a file via the shell
expressionist < data.jsonOutput for the first command:
{
"a": 1,
"b": 2,
"c": 3,
"f": 1.0471975511965976
}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 |
expressionist --method recursive --tag @ --compact '{"a":1,"b":"@a + 1"}'
# -> {"a":1,"b":2}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:
cmake -Bbuild -GNinja -DEXPRESSIONIST_BUILD_PYTHON=ON
cmake --build build
cmake --install buildEXPRESSIONIST_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:
from expressionist import Expressionist, EvalMethod
ex = Expressionist(method=EvalMethod.RECURSIVE)
ex.set_tag("$")
result = ex.evaluate({"a": 1, "b": 2, "c": "$a + b"})
print(result["c"]) # 3evaluate() 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.
{
"ints": "$1:5", // -> [1, 2, 3, 4, 5]
"halfs": "$0:1:0.25", // -> [0.0, 0.25, 0.5, 0.75, 1.0]
"down": "$1:0:-0.5", // -> [1.0, 0.5, 0.0]
"angles":"$0:2*pi:pi/2", // -> [0.0, 1.5707…, 3.1415…, 4.7123…, 6.2831…]
"n": 4,
"upto": "$1:n" // -> [1, 2, 3, 4] (bounds may be expressions)
}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.
{
"base": 10,
"inner": {
"x": "$base + 5", // -> 15 (sees the outer `base`)
"y": "$x * 2" // -> 30 (sees the sibling `x`)
}
}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:
{
"a": 1,
"c": "$a + 10", // -> 11, evaluated normally
"frozen": {
"expressionist": false,
"b": "$a + 1", // left as the literal string "$a + 1"
"inner": { "d": "$a + 2" } // untouched too, however deeply nested
}
}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:
ex.setDisableKey("skipEval"); // now "skipEval": false is the opt-out
ex.setDisableKey(""); // opt-out mechanism disabled| 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.
cmake -Bbuild -GNinja -DCMAKE_BUILD_TYPE=Release
cmake --build build
ctest --test-dir build --output-on-failureThis 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:
doxygen Doxyfile # generates doc/html (gitignored, not versioned)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:
./build/test/expressionist_tests --test-case="*benchmark*" --successLicensed under the Apache License, Version 2.0.
