Skip to content

ExprContainer with value semantics - #592

Open
Krzmbrzl wants to merge 38 commits into
ValeevGroup:masterfrom
Krzmbrzl:value-like-expr-container
Open

Krzmbrzl wants to merge 38 commits into
ValeevGroup:masterfrom
Krzmbrzl:value-like-expr-container

Conversation

@Krzmbrzl

Copy link
Copy Markdown
Collaborator

This is supposed to be an alternative (longer-term perhaps even replacement) for ExprPtr. It can be used to store expressions but contrary to ExprPtr, the new ExprContainer has value semantics. That is, copying the container actually copies the underlying expression. This makes things much easier to reason about and as a side-effect this fixes the const issue of ExprPtr which allows you to do

void func(const ExprPtr &ptr) {
  auto copy = ptr; // only copies _pointer_
  expand(copy);
}

which ends up modifying the original expression ptr was is pointing to. Hence, with ExprPtr we don't have any way to avoid accidental modification.


Note: This PR is based on top of #589

@evaleev

evaleev commented Aug 18, 2026

Copy link
Copy Markdown
Member

@Krzmbrzl would it make sense to do copy-on-mutable-access to save on copying in trivial cases?

@Krzmbrzl

Krzmbrzl commented Aug 18, 2026 •

Copy link
Copy Markdown
Collaborator Author

would it make sense to do copy-on-mutable-access to save on copying in trivial cases?

I don't have experience implementing something like this but it seems like that would significantly complicate the implementation, no? Also, I would really like to not give up on the implicit conversion to (const) Expr &(&) which is what almost all operations act on. Having a copy-on-write seems like it would require introducing something like ExprContainer::to_expr() and Expr::to_mutable_expr() where the former returns const Expr & and the latter returns a copy (which would then still have to be stored in its own container again) 🤔

EDIT: Or are you strictly speaking about the copies performed during construction of ExprContainer instances themselves?

@Krzmbrzl
Krzmbrzl force-pushed the value-like-expr-container branch from b542c43 to 96d4c94 Compare August 20, 2026 10:40
@Krzmbrzl
Krzmbrzl force-pushed the value-like-expr-container branch from 09828aa to eabb51d Compare August 28, 2026 08:27
@Krzmbrzl
Krzmbrzl marked this pull request as ready for review August 28, 2026 08:28
@Krzmbrzl Krzmbrzl added the feature New feature label Aug 28, 2026
@Krzmbrzl

Krzmbrzl commented Aug 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

@evaleev Here's my proposal for how to proceed with this change:

  1. Decide on the final name for ExprContainer - see below
  2. Merge this PR as-is (or with ExprContainer renamed to whatever we decide)
  3. Perform migrations in a series of separate PRs
    1. Deprecate copy and copy-assignment ctor's of ExprPtr and fix all warnings this causes
    2. Deprecate "forwarding" interface of ExprPtr. With that, I mean all member functions that allow to do ptr.func(…) instead of having to do ptr->func(…), e.g. such as member is<…>() and fix all warnings this causes
    3. Get rid of all cases in which we use a null-ExprPtr. That is, deprecate the default-ctor, deprecate conversion to bool that checks whether the ptr is null or not, deprecate comparison overloads for nullptr, etc. In other words, make all code work under the assumption that a provided ExprPtr is always non-null. In certain cases such as canonicalize(…) this will require a change in the return type that should either return optional<Constant> or Constant and we return 1 in case no sign change is necessary. This change is all about getting rid of expression objects that don't actually represent any expression.
    4. Make ExprPtr implicitly convertible to Expr & and change function interfaces that currently take a const ExprPtr & reference and instead make them take a const Expr &. Due to the pointer semantic of ExprPtr, taking ExprPtr instead of Expr & does not signify any kind of ownership transfer and hence the conversion should be preserving existing semantics. Any issues caused in function bodys due to the need of an ExprPtr are most likely signs of a forgotten clone to prevent changing the input.

Up to this point, all changes should be backwards-compatible except for changing things like the return type of the internal canonicalize functions, which I don't expect to really appear in downstream user code.

Finally, changing how Sum and Product store expressions will require changing the interface by which we iterate over expressions to iterate over either ExprContainer & or Expr & where the former would be in-line with current semantics of being able to overwrite e.g. a summand with a different expression while iterating over the sum. The latter would forbid that. I don't think there is a way to make this change backwards-compatible. Hence, I would propose using this change as a reason to switch SeQuant to major version 3. In the same change, we could also completely get rid of the ExprPtr class.

An alternative strategy for the final step would be to remove the ExprPtr class and rename ExprContainer to ExprPtr. This would also represent a breaking change unless we mirror all deprecated APIs of the latter in the former (which I don't think is a good idea) and even then I dislike using the name ExprPtr for something that is very intentionally designed to not behave like a pointer.

In a bump to SeQuant v3, we could also perform renaming of e.g. Expr -> ExprBase, if we feel like it.


About the name

In the meeting ExprValue instead of ExprContainer was suggested. After thinking a bit more about it, I'm not sure I am quite convinced. ExprValue doesn't seem quite as descriptive to me. After all, the whole point is that you only need ExprContainer to store an expression. Most functions work on references anyway and these should just take a reference of Expr and not of ExprContainer in most cases. That would also prevent the issue we currently have that you have to convert an expression to an ExprPtr just for the sake of being able to call a function. ExprValue seems like it would semantically more encourage writing functions taking a reference to it rather than a Expr.

If ExprContainer (13 chars) is too long for your taste, I would propose one of ExprHolder (10), ExprCrate (9) or ExprStore (9). My favorite being ExprHolder (by quite a margin). In fact, I think I might like ExprHolder even more than ExprContainer 🤔

Copy vs Clone

The new ExprContainer doesn't have a clone() function. Instead, it provides a copy() function. The reason why I chose to switch to a different API is that clone as a public API function is not needed once we use ExprContainer. Due to its value-semantics, it will implicitly clone the represented expression whenever it is copied (or whenever a non-rvalue expression is assigned to it). Hence, I believe that all code that currently uses clone() should just use this regular copy-semantic instead.

Then why create a copy() function instead? Because the copy ctor of ExprContainer is explicit and always writing ExprContainer(other) to copy is cumbersome. This is only required for copies though. The move ctor is implicit. Hence, the copy() function is merely intended as syntactic sugar for the explicit copy ctor whereas clone has an important semantic meaning (distinct from just copying the object). Therefore, I figured that these functions should have different names.

@evaleev evaleev added this to the 2.4 milestone Sep 28, 2026
@Krzmbrzl

Copy link
Copy Markdown
Collaborator Author

@evaleev how do you want me to proceed with this? (see writeup above)

@evaleev evaleev left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the write-up and the ping. I built the branch in a worktree (Debug, SEQUANT_ASSERT_BEHAVIOR=THROW) and checked each point below with a compile or runtime probe; CI is green only because the tests never exercise these paths. Verdict: needs changes before merge, and the branch has to be rebased first (540 commits behind, 7 conflicting files: constant.cpp, expr.cpp, five under export/; master now has 15 clone() const override sites that must become unique_copy, and #686 rewrites Power::flatten).

Blocking

  1. Container-with-container arithmetic is broken for all four operators. a + b, a - b, a ^ b with two ExprContainers are ambiguous between the (ExprContainer, Expr) and (Expr, ExprContainer) overloads. a * b compiles because expr_container.hpp#L78 declares a (ExprContainer, ExprContainer) overload, but nothing defines it, so it fails at link time; line 79 re-declares the (Expr, Expr) form instead of the missing mixed one.
  2. Sum(container) and Product(container) silently produce an empty sum/product. ExprContainer has begin()/end() over subexpressions, so it satisfies the range constraint of the range constructors (sum.hpp#L60, product.hpp#L53). A container holding a Variable yields a Sum of size 0 with no diagnostic; same for ex<Sum>(cont).
  3. Sum::filter no longer compiles when instantiated. The transform lambda at sum.hpp#L119 returns *e by deduced value, i.e. the abstract Expr. No in-tree callers, so it is invisible until someone uses it.

Should fix

  • The "move" constructor deep-copies. ExprContainer(Expr&&) (expr_container.cpp#L31) calls the const unique_copy(); the source is left intact (probe: a Sum with 3 summands still has 3 after being "moved" into a container). Every *this = Sum(...) inside the in-place operators therefore clones the freshly built tree a second time. The explicit-copy/implicit-move split in the design rests on a move that does not exist; a real move needs a virtual rvalue hook.
  • Moved-from containers are undetectable. After a move or take_expr(), expr_ is null. operator*, operator-> and the copy ctor have no assert, so copying a moved-from container segfaults even under THROW; only begin()/end() assert. ExprContainer(const ExprPtr&) with a null pointer dereferences before the assert at line 25 can fire.
  • Root-level visit semantics changed silently. A const ExprPtr& visitor now receives expr.clone() for the root (expr.hpp#L422); before it received the real object via exprptr_from_this, so mutations through the pointer and identity checks applied. Probe: a visitor relabelling the root Variable now has no effect. The in-tree const ExprPtr& visitors (wick.impl.hpp, utility/indices.hpp, biorthogonalization.cpp) only read, so nothing breaks today, but downstream code can.
  • The deprecated hash_value(shared_ptr hasher) overload deep-clones the whole expression on every call (expr.hpp#L270) and hands the hasher a different object, so identity-keyed hashers change answers.
  • Explicit copy ctor makes std::copyable<ExprContainer> false. std::vector<ExprContainer>{a} does not compile, nor will any std::ranges/range-v3 view or algorithm requiring copyable elements. This matters directly for the plan to store containers in Sum::summands_.
  • Duplicated arithmetic semantics. The four in-place ExprContainer operators re-implement the ExprPtr operator logic in expr_ptr.cpp; the two have already diverged (container clones factors, pointer aliases them). Per AGENTS.md this should be one implementation.
  • No scalar arithmetic. cont + 2 does not compile while ptr + 2 does, since expr_operators.hpp was narrowed to exactly ExprPtr.
  • Test gaps: container-with-container operators, ^=, adjoint(ExprContainer), iteration, take_expr, Power::flatten(ExprContainer&), Power::unique_copy with conjugation, Sum::filter, visiting an expression not managed by shared_ptr (the removed throw).

Minor

  • ExprContainerList (expr_container.hpp#L67) is unused, and with an explicit copy ctor elements cannot be copied out of it.
  • Power(lvalue_container, exp) does not compile (by-value parameter + explicit copy). Fine if intended, but should be documented.
  • Typos: flatt_impl (power.cpp#L41), test sections "from conrete", "Variabkle", "from base".
  • Several commits fix earlier commits in the same PR ("Move begin/end impl to cpp file (and fix it)", "Fix deprecation warning in test_expr", "Include supporting header"); please squash those into the commits they fix.
  • After rebase, doc/user/guide/expressions.rst needs the new type and the changed visit contract.

On the proposal

  • Merge as-is: not possible; the three blocking items plus the rebase come first.
  • 3.1 deprecate ExprPtr copy: not backwards-compatible in practice. ExprPtrList is an initializer_list, every summands() range copies pointers, and MPQC builds with warnings as errors. It also lands before the storage change that would make it natural, so call sites get churned twice.
  • 3.3 remove null ExprPtr: the accumulator idiom (ExprPtr r; for (...) r += x;) is everywhere, and ExprContainer has no empty state and no default ctor. The proposal needs an answer for that before deprecating null, beyond the canonicalize return types.
  • 3.4 interfaces take const Expr&: sound, but functions that replace the root (simplify, expand, Power::flatten) must take ExprContainer&, which forces callers holding ExprPtr to copy.
  • Final step (containers inside Sum/Product, v3): the copyable issue above bites here. There is also no performance evidence yet; subtree sharing and memoized hashes are lost under value semantics, so I would like a benchmarks/ run before committing to it.
  • Name: "Container" is a poor fit because the class is itself an iterable range over subexpressions, so readers will ask "container of what". ExprHolder avoids that; I am fine with it.
  • Copy vs copy(): reasonable, but the design should state outright that the type is not std::copyable and what that implies for ranges.

The core idea is right and the unique_copy refactor is clean. Fix the blocking items, make the rvalue path actually move, rebase, and then the migration sequence is worth settling, with 3.1 and 3.3 reconsidered as above.

@Krzmbrzl
Krzmbrzl force-pushed the value-like-expr-container branch 3 times, most recently from aff01aa to 929166f Compare October 28, 2026 13:20
The new function returns a unique_ptr<Expr> instead of an ExprPtr.
Reason being that this gives much more flexibility such as moving the
ownership of the object out of the smart pointer or simply using as a
unique_ptr. Since unique_ptr is implicitly convertible to a shared_ptr
(via move ctor), conversion to ExprPtr is trivially possible.

To retain compatibility with existing interface, Expr now implements a
clone() function by means of the new unique_copy().
This is in the way of having expression objects that are not managed by
shared_ptr
"Container" is a poor fit as the class is itself iterable over the
subexpressions of the held expression, which invites the question
"container of what?". ExprHolder describes its purpose (holding a single
expression with value semantics) without that ambiguity.

AI-assisted
With an explicit copy constructor, std::copyable<ExprHolder> is false,
meaning that e.g. std::vector<ExprHolder>{a} or range views/algorithms
requiring copyable elements can't be used with ExprHolder. This would
become a problem once Sum and Product store their subexpressions as
ExprHolder objects.

Copying an ExprHolder still performs a deep copy of the held expression,
i.e. value semantics are retained.

AI-assisted
ExprHolder(Expr &&) used to call unique_copy() as the dynamic type of
the passed object is unknown and hence there was no way to move it into
a new heap allocation. Therefore, the "move" constructor deep-copied the
expression, which also meant that every in-place ExprHolder operator
that builds a new expression and assigns it to the holder copied the
freshly built tree a second time.

The new pure virtual Expr::unique_move() && moves an object into a new
heap allocation of its dynamic type, which allows ExprHolder(Expr &&) to
perform an actual move. Every Expr subclass needs to implement it.

AI-assisted
Arithmetic with two ExprHolder operands was ambiguous for +, - and ^
between the overloads taking ExprHolder for the left and for the right
operand. For * a (ExprHolder, ExprHolder) overload was declared but
never defined, leading to a linker error. Additionally, the (Expr, Expr)
overload of operator* was declared twice whereas the mixed one was
missing.

Now, all four operators provide all four overloads. The left-hand side
is taken by value such that rvalue holders are reused instead of copied.

Additionally, arithmetic between ExprHolder and scalars (or variable
labels) is now supported in the same way as it is for ExprPtr.

AI-assisted
ExprHolder is iterable over the subexpressions of the held expression and
therefore satisfies the range constraint of the range constructors of Sum
and Product. Hence, constructing a Sum or Product from an ExprHolder
silently iterated over the subexpressions of the held expression, which
e.g. for a held Variable produced an empty Sum.

Now, an ExprHolder is treated the same way as an ExprPtr or an Expr
object, i.e. as a single summand/factor.

AI-assisted
The transform lambda returned the dereferenced summand by value, i.e. an
object of the abstract type Expr, which meant that Sum::filter failed to
compile as soon as it was instantiated.

The predicate is now applied to the dereferenced summands directly.
Additionally, predicates taking a const ExprPtr & are accepted again in
order to remain compatible with existing code.

AI-assisted
After a move or take_expr(), an ExprHolder no longer holds an expression.
Previously, only begin() and end() asserted on that, while all other ways
of accessing the held expression (including copying the holder)
dereferenced a null pointer. Similarly, constructing an ExprHolder from a
null ExprPtr dereferenced the pointer before any check could fire.

All accesses now go through accessors asserting that the holder is not in
a moved-from state.

AI-assisted
AI-assisted
Covers iteration, adjoint(), Power with ExprHolder (incl. flattening and
copying of conjugated powers), visiting expressions that are not managed
by a shared_ptr as well as the root of those that are, and the
deprecated shared_ptr-based hasher overload of Expr::hash_value.

AI-assisted
A visitor's signature now determines what it may do with the visited
expressions, consistently for all of them, including the expression
visit() is called on, regardless of how that one is managed:
- ExprPtr &: replace subexpressions (does not visit the root)
- Expr &: modify in-place (non-const visit() only)
- const Expr &: inspect only

Since const ExprPtr & does not propagate constness to the pointee, such
visitors could modify every visited expression except the root, which
can't be passed as an owning pointer without requiring it to be managed
by a shared_ptr. Hence, these visitors are deprecated. Until they are
removed, they receive a non-owning pointer to the root.

All existing visitors are migrated to the new signatures.

AI-assisted
ExprHolder's in-place operators moved the held expression away before
cloning the other operand, which breaks if that operand is (part of) the
held expression, e.g. `h += h`. Keep the held expression alive until the
result has been formed instead.

Sum and Product gain append_impl/prepend_impl taking a const Expr &,
which clone the operand only once and handle appending an expression to
itself. Also let operator/ accept const and lvalue arithmetic operands.

AI-assisted
Both report the type id of Product, hence Expr::is/as can't tell them
apart from Product. Turn such uses into a compile-time error and point
to is<Product>() and Product::is_commutative() instead.

AI-assisted
@Krzmbrzl
Krzmbrzl force-pushed the value-like-expr-container branch from 929166f to a0bc6ed Compare October 28, 2026 14:54

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants