Skip to content

Rethink ZipperValues::val_at for computed / algebraic zippers #68

Description

@marcin-rzeznicki

Problem

The current ZipperValues trait is defined roughly as:

pub trait ZipperValues<V> {
    fn val(&self) -> Option<&V>;

    fn val_at<K: AsRef<[u8]>>(&self, path: K) -> Option<&V>;
}

This works well for zippers backed by a concrete trie, where values already exist in storage and both methods can return references into that storage.

However, this becomes problematic for computed or algebraic zippers, such as a virtual SubtractZipper<A, B> representing A - B.

At the current focus, SubtractZipper can cache the computed value internally, so implementing:

fn val(&self) -> Option<&V>

is straightforward.

For val_at, however, subtraction may need to construct a new value:

match lhs_val.psubtract(&rhs_val) {
    AlgebraicResult::None => None,
    AlgebraicResult::Identity(...) => lhs_val,
    AlgebraicResult::Element(v) => ??,
}

In the Element(v) case, v is newly created by the operation. It cannot be returned as &V, because it is only a temporary local value.

This means the current val_at(&self) -> Option<&V> contract assumes that every value queried through a zipper already has stable backing storage, which is not true for virtual/computed zippers.

Possible solutions

Option 1: Return Cow<'_, V> from val_at

Change the API to something like:

fn val_at<K: AsRef<[u8]>>(
    &self,
    path: K,
) -> Option<Cow<'_, V>>
where
    V: Clone;

This naturally represents both cases:

Cow::Borrowed(value)

when the result already exists in the underlying trie, and:

Cow::Owned(value)

when an algebraic zipper needs to synthesize a new value.

For example, subtraction could implement:

match lhs_val.psubtract(&rhs_val) {
    AlgebraicResult::None => None,

    AlgebraicResult::Identity(mask) if mask == SELF_IDENT => {
        lhs_val.map(Cow::Borrowed)
    }

    AlgebraicResult::Element(v) => {
        v.map(Cow::Owned)
    }

    _ => unreachable!(),
}

Advantages

  • val_at works uniformly for concrete and computed zippers.
  • The return type accurately describes the semantics: a value may either be borrowed or synthesized.
  • Callers can usually treat Cow<V> similarly to &V via Deref.
  • Computed zippers do not need artificial caches or interior mutability.

Disadvantages

  • Changes the existing API for all ZipperValues implementations and callers.
  • Introduces Cow into a relatively low-level zipper interface.
  • May require a V: Clone bound, depending on how the API is structured.
  • Callers that only deal with concrete zippers now receive a more general type than they actually need.

Option 2: Split focused-value and arbitrary-path value capabilities

The issue also suggests that val() and val_at() may represent two fundamentally different capabilities.

val() queries the zipper's current focus. A virtual zipper can maintain/cache the value at its focus.

val_at() queries an arbitrary descendant without moving the zipper. A computed zipper may need to synthesize that result dynamically.

We could therefore split the trait:

pub trait ZipperValue<V> {
    fn val(&self) -> Option<&V>;
}

pub trait ZipperValues<V>: ZipperValue<V> {
    fn val_at<K: AsRef<[u8]>>(
        &self,
        path: K,
    ) -> Option<&V>;
}

Concrete trie zippers could continue implementing ZipperValues<V> exactly as they do today.

Computed zippers such as SubtractZipper could implement only:

ZipperValue<V>

unless they can provide stable references for arbitrary paths.

If arbitrary-path access to computed values is needed, we could add a separate capability, for example:

pub enum ValueRef<'a, V> {
    Borrowed(&'a V),
    Owned(V),
}

pub trait ZipperComputedValues<V> {
    fn val_at<K: AsRef<[u8]>>(
        &self,
        path: K,
    ) -> Option<ValueRef<'_, V>>;
}

or use Cow<'_, V> there instead.

Advantages

  • Preserves the existing zero-copy Option<&V> API for concrete zippers.
  • Makes the capability distinction explicit.
  • Computed zippers do not have to implement an operation they cannot naturally support.
  • Avoids imposing Cow or ownership semantics on all zipper users.

Disadvantages

  • Adds another trait/capability to the zipper API.
  • Generic code that currently requires ZipperValues<V> may need to reconsider whether it actually needs val_at.
  • Code that needs arbitrary-path access across both concrete and computed zippers may still require another abstraction.

Why not cache values returned by val_at?

A SubtractZipper could theoretically cache synthesized values by path and then return references into that cache.

However, this seems undesirable:

  • val_at(&self) would require interior mutability.
  • A normal RefCell/RwLock cache cannot trivially return a plain &V after releasing its guard.
  • Stable-reference storage would require something closer to an arena or append-only allocation scheme.
  • The cache could grow with every queried path.
  • This adds significant complexity merely to preserve an API contract that does not naturally fit computed zippers.

Therefore, caching arbitrary val_at results does not appear to be a good general solution.

Question

Which API direction do we prefer?

  1. Generalize val_at to return Cow<'_, V>, allowing both borrowed and synthesized values through one interface.

  2. Split the trait, keeping val() -> Option<&V> as the basic capability and reserving the current reference-returning val_at() for zippers backed by stable value storage.

A hybrid is also possible: split the traits first, then provide a separate computed-value val_at capability returning Cow<'_, V> or a custom Borrowed | Owned enum.

The decision mainly depends on whether we consider arbitrary-path value lookup an essential operation that should work uniformly across all zipper types, or an optional capability of zippers with suitable backing storage.

Metadata

Metadata

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions