Skip to content

Main v0.3 changes: foreign shims, reflexive impls, and trait binding#1

Open
nixpulvis wants to merge 27 commits into
masterfrom
next
Open

Main v0.3 changes: foreign shims, reflexive impls, and trait binding#1
nixpulvis wants to merge 27 commits into
masterfrom
next

Conversation

@nixpulvis

Copy link
Copy Markdown
Owner

This branch is a large feature drop.

Review the CHANGELOG.md first, its [Unreleased] section is the better list of what changed.

Highlights:

  • #[dyn_shim_foreign] for shimming a trait from another crate.
  • Reflexive impls (reflexive = bare | boxed) so an erased value satisfies the source trait, with erase / boxed / stub / panic remediations for methods that can't forward.
  • #[dyn_shim_bind] to mount a carrier (DynClone, DynHash, or any shim) onto a trait object that can't list it as a supertrait.
  • Standalone DynClone / DynHash shims behind features, as near drop-in replacements for the dyn-clone / dyn-hash crates.

nixpulvis added 27 commits June 15, 2026 08:10
Extract the per-method reflexive forwarding bodies into reflexive_entries
and emit a #[macro_export] macro_rules! named after each shim that stamps
impl SourceTrait for <object>. The reflexive option now invokes that macro
(@bare/@boxed) instead of emitting the impls inline.

This is an internal refactor with no behavior change: the macro is the
shared linking mechanism that a downstream #[trait_object(Shim)] will also
invoke (@mount) to mount the source trait onto its own principal.
trait_object now names the carrier trait to mount (DynClone, DynHash, or
any #[dyn_shim] shim) rather than the Clone/Hash capability, and emits no
impls itself: it invokes each carrier's generated @mount macro, the same
linking mechanism reflexive uses. This unifies Clone/Hash with arbitrary
shims:

- dyn_shim_recognized now generates a @mount macro for DynClone/DynHash
  carrying the bridge impls (replacing the inline trait_object_bridge).
- #[trait_object(DynFoo)] mounts a shim's source trait onto a different
  principal that inherits the shim, e.g. impl Foo for dyn Bar where
  Bar: DynFoo. This is the general case the old Clone/Hash-only form
  could not express.
- trait_object is no longer feature-gated, since a shim carrier needs no
  feature; the DynClone/DynHash carriers it can name still require theirs.

Surface change: #[trait_object(Clone)] becomes #[trait_object(DynClone)].
Tests, examples, and UI snapshots updated.
Rewrite the trait_object docs (macro, README, crate root) around mounting
a carrier onto a trait object: the shipped DynClone/DynHash carriers and,
generally, any #[dyn_shim] shim mounting its source trait. Add the
mount_trait example showing #[trait_object(DynPriced)] giving dyn Product
a non-dyn-compatible Priced, and cross-link it from the trait_object
example.
Restructure the README as a few short, compelling use cases (mixed
Box<dyn> collections, Clone/Hash on trait objects, reflexive/trait_object
mounting, foreign traits) that direct to the rustdoc for the full rules.
Reword carrier-rejection comments to describe behavior rather than past
spellings.
The Rule example had only a dispatchable method, so dyn Rule already
worked and the shim was pointless. Give Rule a generic threshold method
that rules out dyn Rule, so the shim and reflexive impl are what enable
the erased value at all.
A method that is non-dyn-compatible only because of a generic type
parameter (or argument-position impl Trait) bounded by a single trait
and used behind a reference can now be kept in the shim instead of
skipped, by annotating it #[dyn_shim(erase)]. The shim lowers the
parameter to a trait object (&mut impl Write -> &mut dyn Write) and the
forwarding call reborrows the argument so the source method's parameter
re-infers to the sized reference type, sound whenever the bound provides
impl Bound for &mut (dyn Bound) (as std does for Hasher, Write, Read).

The transform is extracted into erase_generic and ErasedFn, a shared
substrate: the recognized Hash bound's hidden __dyn_shim_hash method,
which previously hand-rolled exactly this erasure of Hash::hash's generic
hasher, is now generated by running a synthetic fn(&self, &mut impl
Hasher) through erase_generic. forward is split so an erased method routes
through forward_erased, and skip suppresses only the generic / impl-Trait
disqualifiers under erase (never Self, which cannot be erased).

erase_generic rejects what it cannot lower with a method-site error: a
const generic, a multi-bounded parameter, or a parameter used by value,
in the return type, or in more than one argument.

Tests: erased dispatch through Box<dyn Shim> for both the named-generic
and impl-Trait forms, a UI snapshot for the return-type rejection, and
the existing Hash suite continues to pass through the refactored carrier.
A `-> Self` builder is non-dyn-compatible (Self is unsized as the trait
object) and was either skipped or, in a reflexive impl, given a panicking
stub. #[dyn_shim(boxed)] instead makes the shim method return
Box<dyn Shim>: the blanket impl boxes the concrete result, which unsizes
to the shim object because the implementor is Shim and the method
requires Self: 'static (a bound that, unlike Self: Sized, keeps the
method in the vtable). A reflexive = boxed impl then satisfies the source
-> Self, since there Self is exactly Box<dyn Shim>, so a consuming or
chaining builder keeps working on an erased value instead of panicking.

This is the general form of the boxing the recognized Clone bound applies
to clone. forward gains the shim name and routes a boxed method through
forward_boxed; skip suppresses only the Self disqualifier under boxed.
forward_boxed rejects shapes it cannot box (a non-Self return, a generic
method, Self in an argument), and the macro rejects combining boxed with
auto-trait markers on a reflexive shim, since Box<dyn Shim> cannot carry
+ Send.

Tests: a consuming and a by-ref builder dispatched through Box<dyn Shim>
and through an impl-Step-by-value boundary (the boxed reflexive impl),
plus a UI snapshot for the marker rejection.
Extract build_boxed_method: given a method signature and a Self-typed
expression, it fills in the -> Box<dyn shim markers> return and the
where Self: 'static markers bound, and boxes the expression. This is the
boxing half of #[dyn_shim(boxed)], factored out the way erase_generic is
the erasure half shared with the Hash carrier.

expand_clone now generates each per-combo __dyn_shim_clone_box method
through build_boxed_method (its boxed expression is Clone::clone(self)),
and forward_boxed builds a -> Self builder's shim method through the same
primitive (boxed expression: the forwarded source call). Clone is thus
the marker-combo consumer of the boxing substrate, mirroring how
__dyn_shim_hash consumes erase_generic. No behavior change: the full
Clone suite, including every auto-trait marker combination and the
ToOwned / DynClone-upcast paths, passes unchanged.

The source-builder marker gate stays: a boxed builder's reflexive impl
forwards through the shared, combo-independent mount entries, which can
only satisfy the marker-free Box<dyn shim>, whereas Clone re-attaches the
marker in its own per-combo bridge.
Record what lifting #[dyn_shim(boxed)]'s auto-trait-marker gate would
take (suffixed shim methods plus per-combo mount routing) at the gate
itself, so the boundary is documented where it is enforced.
A #[dyn_shim_foreign] method with a default body is now provided by the
shim rather than forwarded: it is emitted verbatim on the shim trait and
left out of the blanket impl, the reflexive impl, and the mount macro, so
its body computes from the shim's forwarded methods. This lets a foreign
shim add a convenience method the foreign trait does not declare;
previously the macro forwarded it to a <T as Source>::method call that
did not exist and failed to compile.

The local #[dyn_shim] form is unchanged: a default body lives on the
re-emitted source trait and is forwarded like any other method, so an
implementor's override is still honored (is_provided returns false there).

Also adds a test that a default body on an UNFORWARDABLE method already
works through a reflexive impl: a generic method is skipped from the shim
and omitted from impl SourceTrait for Box<dyn Shim>, which inherits the
source default and dispatches its forwarded calls back through the shim.

Tests: foreign provided default callable on Box<dyn Shim> and excluded
from the reflexive impl (so the box still satisfies the foreign trait),
plus the local reflexive default-inheritance case. Docs: a Provided
methods section on dyn_shim_foreign and a README note.
Two more remediations for a method that cannot forward through the shim
in a reflexive impl, beyond the panicking stub:

#3 `where Self: Sized` on a `reflexive = bare` impl: such a method is not
part of the unsized object's surface, so it is omitted from
`impl Foo for dyn DynFoo` entirely. Calling it on a `&dyn DynFoo` is then
a compile error (Sized not satisfied) rather than a runtime panic.
Previously the macro rejected the shim, since the method could neither
forward nor be stubbed in the bare form. The boxed object is Sized, so a
`boxed` impl still needs a stub or default.

#5 `#[dyn_shim(stub = <expr>)]`: a custom fallback body for the erased
impl, so the method can degrade to a value (None, Default::default(), ...)
instead of aborting. `#[dyn_shim(panic)]` is now the sugar for it: the
Helper::Panic variant is replaced by Stub(Option<Expr>), where None is
the panic-with-generated-message case and Some(expr) is the custom body.
Helper loses Copy/PartialEq (Expr is neither); the equality checks become
matches!.

Tests: bare omits a required Self:Sized method (compiles, and a UI
snapshot pins the compile error on &dyn); a stub = None method degrades
to None through the reflexive impl. Docs: the reflexive remediations are
now a most-to-least-preferable list in dyn_shim, the foreign helper note
lists stub/erase/boxed, and the README reflexive note is rewritten. UI
snapshots for the widened helper-argument and unstubbed-method messages
regenerated.
Record at the forward_boxed generic-method rejection what supporting a
generic -> Self builder would take: the two transforms touch disjoint
parts of the signature (erase the arguments, boxing the return), so
composing them is mechanical but needs a way to request both helpers on
one method and a forwarder that runs erase_generic then boxes.
Rewrite the reflexive example around the remediation ladder for methods
that cannot forward through the shim: explain<W: Write> via erase (real
forwarding), parsed<T> via stub = None (graceful degradation), and
threshold<T> via panic (last resort), alongside the existing bare+boxed
bridge. Add a builder example for #[dyn_shim(boxed)], which needs
reflexive = boxed (a bare dyn cannot be a returned Self) so it cannot
share the bare+boxed reflexive example.

Also silence unused-variable warnings on a stub: the restated signature
keeps the argument names but a panic/stub body ignores them, so emit
#[allow(unused_variables)] on stub methods. Previously latent because the
only stubbed methods in tests were argless (fresh/make); surfaced by a
stubbed parse(&self, text).
The examples are named after the dyn_shim concept they demonstrate
(clone, hash, reflexive, trait_object, foreign); the new one shows
#[dyn_shim(boxed)], so name it boxed.rs to match, not after the builder
pattern it happens to use.
README: drop the mechanics paragraphs added this session (erase, the
remediation ladder, foreign provided-defaults) so it is again a few short
use cases that defer to the docs. The reflexive section keeps a one-line
mention of the remediations and trait_object mounting; the Documentation
section now enumerates what rustdoc covers (the per-method helpers,
reflexive, recognized bounds, trait_object, foreign, features).

Rustdoc review:
- dyn_shim Method Selection: note that a generic/impl-Trait argument can
  be erased and a -> Self builder boxed, cross-linked to those sections,
  so the skip list points at its remediations.
- Reflexive Impl: the boxed paragraph re-introduced boxed right after the
  ladder already listed it; reframe it as the detailed elaboration.
- crate root: fix the unindented list continuations (rustdoc/clippy
  warnings) and reorder so the colon introduces the DynClone/DynHash list
  directly rather than a paragraph.

cargo doc --all-features passes under -D warnings (no broken links).
Drop the use-case sections and all mechanics prose; the README is now
just one dyn_shim example (a mixed Box<dyn DynParser> collection from a
non-dyn-compatible Parser) and the dyn_hash drop-in, pointing to the
rustdoc for everything else.
The attribute that mounts a carrier onto an already-dyn-compatible trait
is renamed from trait_object to dyn_shim_bind, putting it under the same
prefix as dyn_shim and dyn_shim_foreign. The new name reflects what it
does: it binds a carrier's capability (a dyn_shim shim's source trait, or
the shipped DynClone/DynHash) onto dyn YourTrait, so the object satisfies
a trait it cannot list as a supertrait.

The internal mechanism follows the same rename: the generated per-carrier
mount macro becomes a bind macro, with @Bind arms, build_bind_macro, and
bind_arm. Where "mounted impl" would collide with trait-bound wording it
is reworded to "generated impl".

The external dyn-clone/dyn-hash macros (clone_trait_object!,
hash_trait_object!) and a struct field named trait_object keep their
names; only the attribute and its machinery are renamed.
The @Bind macro arm re-spelled the Clone/ToOwned/Hash impls that
clone_bridge and hash_bridge already emit for the recognized-bound
path. Generalize those emitters to a ToTokens principal so the arm
can hand in the $principal/$marker metavariables as literal tokens
and delegate, leaving one source of truth for the bridge impls. The
two paths now differ only in the carrier: a generated shim method
versus __clone_box. Generated tokens are unchanged.
erase_by_value.rs pins the macro's direct rejection of a by-value
generic parameter (no reference to lower). erase_bound_not_forwarding.rs
pins the natural rustc error when a sized parameter's bound has no
reference-forwarding impl, so the reborrowed `&mut dyn Bound` does not
satisfy it. The first snapshot is macro-emitted and toolchain-stable;
the second is a rustc trait error, blessed under 1.96.0.
#[dyn_shim(erase)] reborrows an erased argument so the source method's
type parameter re-infers to the sized reference type `&mut dyn Bound`,
which needs an `impl Bound for &mut dyn Bound`. A `?Sized` parameter has
no such constraint: the lowered object can be forwarded by name, so the
parameter infers to `dyn Bound` itself, satisfied by any object-safe
bound. erase_generic records a per-parameter ?Sized flag and picks the
new Erased::Direct forwarding for it; sized parameters and impl Trait
arguments keep reborrowing.
Run rustfmt across the workspace, drop needless borrows on the
hash-comparison baselines in the integration tests and the dyn_hash
example, and allow the intentional `fn by_self(self: Self)` arbitrary
self type that exercises by-value receiver rewriting.
Reduce the overlapping example set from ten programs to five, each
covering one capability: shim (core), clone_and_hash (recognized
bounds), bind (dyn_shim_bind with both carrier kinds), reflexive
(reflexive impls and remediations), and foreign. Point to them from
the README.
A `#[dyn_shim_foreign]` method with a default body is shim-local: the
shim trait is its only home, since there is no source trait to fall back
to. When such a method is not dyn-compatible it cannot be a method of the
shim, so it was dropped silently along with its body. Error instead,
naming the reason and the ways to resolve it.

The local form is unaffected: it re-emits the source trait with the
default intact, where it is still reached on concrete types and inherited
through a reflexive impl, so skipping there loses nothing.

Detection is signature-based, so a method made non-dyn-compatible only by
a `where Self:` bound other than literal `Sized` still slips through; the
dyn_shim_foreign docs now name that limitation.
Comment thread examples/bind.rs
Comment on lines +58 to +59
// `Product` is dyn-compatible and is the type held behind `dyn`. The three
// carriers bind `Priced`, `Clone`, and `Hash` onto its objects.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

You mention "carriers" in the top-level documentation. Now that we are seeing it in action, reuse that term in this example.

Also, show this partially expanded. Folks new to this idea aren't thinking in terms of "shims", and, if they are reading examples, they are new to this idea. Rather, they are thinking in impl blocks, so show them a couple impl blocks.

Comment thread examples/bind.rs
}

// Generic over `Priced` by reference: a `&dyn Product` satisfies it.
fn shelf_price(p: &(impl Priced + ?Sized)) -> u32 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Needing to specify ?Sized feels intrusive. After all, this is how users will be interacting with the crate. But, I'm not sure if there is a way to work around this generally. For this specific case, it can be omitted if there is a blanket impl for Priced:

impl<T> Priced for &T
  where T: Priced {
   /* ... */
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

What about &mut? self seems impossible... and then there's all the other pointer types if we want to cover this.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

And as for AsRef and AsMut, it could help somewhat, but I'm not sure how to handle traits where only AsRef is available.

@nixpulvis nixpulvis Jun 28, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Another issue here, even if we try to do something smart like detect when a trait needs AsMut by method receivers, I think these blanket impls will collide with reflexive shims, which allows more forwarding than the blanket, but doesn't have these nice ergonomics for calling without ?Sized.

I'm really not sure what's best to do here.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants