Skip to content

Add an IsEmpty derive macro and use it in pos-accounting - #2096

Open
oliv3rdrt wants to merge 2 commits into
mintlayer:masterfrom
oliv3rdrt:is_empty_derive
Open

Add an IsEmpty derive macro and use it in pos-accounting#2096
oliv3rdrt wants to merge 2 commits into
mintlayer:masterfrom
oliv3rdrt:is_empty_derive

Conversation

@oliv3rdrt

Copy link
Copy Markdown
Contributor

Problem

PoSAccountingData::is_empty is written by hand as the conjunction of its five fields' is_empty calls, and carries a // TODO: avoid manual implementation (mintlayer/mintlayer-core#669). As the issue notes, adding a field and forgetting to update such a method is an easy mistake.

Change

Add an IsEmpty trait and a derive macro for it, as a new is-empty / is-empty-derive crate pair that mirrors the existing typename / typename-derive setup. The derive generates is_empty as the AND of each field's own is_empty, so a struct is empty exactly when all of its fields are empty (a field-less struct is always empty). Every field type therefore needs an is_empty method: the standard collections, String, or another IsEmpty type.

Apply the derive to PoSAccountingData, removing the manual implementation and the TODO. pos-accounting re-exports the trait so the existing callers only need it in scope.

Tests

The is-empty crate has unit tests covering named-field, tuple and unit structs, and the empty and non-empty cases. The existing pos-accounting and chainstate tests still pass.

Closes #669

@erubboli

Copy link
Copy Markdown
Member

Nice cleanup. I reproduced the derive in an isolated workspace and checked the generated PoSAccountingData::is_empty is equivalent to the manual one it replaces (same five fields, same order), and that the inherent→trait move breaks nothing — the only three call sites are all in chainstate_accounting_storage_tests.rs, which the PR already updates. The missing utils/is-empty* entries in the workspace members list are fine too, consistent with utils/typename and auto-included as path deps.

One thing I'd like fixed before merge:

The generated impl uses an unqualified trait path (is-empty-derive/src/lib.rs:70): quote! { impl #impl_generics IsEmpty for #ident ... } resolves IsEmpty in the caller's scope. Two consequences, both of which I reproduced:

  • #[derive(is_empty::IsEmpty)] without a separate use is_empty::IsEmpty; fails with error[E0405]: cannot find trait IsEmpty in this scope. Path-qualified derives are a normal pattern, so this will bite someone.
  • Worse, it can be silently wrong: with another trait named IsEmpty in scope, the derive implements that one, and the type does not satisfy is_empty::IsEmpty (rustc even says "implements similarly named trait IsEmpty, but not is_empty::IsEmpty").

Emitting ::is_empty::IsEmpty fixes both. Note the per-field call at line 50 must stay unqualified, since Vec/BTreeMap use an inherent is_empty — worth a line in the doc comment saying a nested IsEmpty-typed field still needs the trait in scope.

Two smaller ones:

  1. is-empty-derive/src/lib.rs:50 — the doc says "every field type must have one", implying a compile error protects you, but the check is plain method lookup. struct WithArray { a: [u32; 4] } compiles (via slice deref) and [T; N] with N > 0 returns false unconditionally, so adding a fixed-size array field silently makes is_empty() permanently false — the exact silent-bug class Introduce IsEmpty derive trait #669 is about. Same for types whose is_empty means something unrelated (NetUpgrades, Services, Script).

  2. pos-accounting/src/lib.rs:40pub use is_empty::IsEmpty; leaks a foreign trait (and, since the name covers both namespaces, the derive macro) into an unrelated crate's public API, and ties its availability to pos-accounting continuing to use it. I'd have chainstate-test-suite depend on is-empty directly. It also makes the shadowing in the finding above more likely, by putting an IsEmpty name into pos_accounting's namespace.

Also, could you rebase onto current master? This branch is based on 6dce99e4, which predates the CI fix from #2102.

PoSAccountingData had a hand-written is_empty that ANDs all of its fields, with a
TODO asking for it to be generated. It is easy to add a field and forget to
update such a method.

Add an is-empty crate with an IsEmpty trait and a matching derive, mirroring the
existing typename/typename-derive pair. The derive implements is_empty as the
conjunction of each field's own is_empty, so a value is empty when all of its
fields are empty. Apply it to PoSAccountingData and drop the manual method;
pos-accounting re-exports the trait so existing callers keep working.
The generated impl named the trait unqualified, so it resolved in the caller's
scope. Deriving through a path, as in #[derive(is_empty::IsEmpty)], failed
with E0405 unless the trait was also imported, and worse, if another trait
called IsEmpty happened to be in scope the derive implemented that one instead
and the type silently did not satisfy is_empty::IsEmpty.

Emit ::is_empty::IsEmpty instead. The per field call stays unqualified on
purpose, since the standard collections provide is_empty inherently. That path
does not resolve inside the is-empty crate itself, so alias the crate to its
own name for the tests, and cover both cases with a test that derives without
the trait imported while a decoy IsEmpty is in scope.

Also drop the IsEmpty re-export from pos-accounting, which existed only to
give deriving users the trait in scope and is no longer needed. The one crate
that calls the trait method now depends on is-empty directly.

The doc comment claimed every field type must have an is_empty, which reads
like a compile time guarantee. It is ordinary method lookup, so say what that
means, including the fixed size array case that would silently make a struct
permanently non-empty.
@oliv3rdrt

Copy link
Copy Markdown
Contributor Author

@erubboli All three done!

Worth flagging: emitting ::is_empty::IsEmpty breaks the is-empty crate's own tests, since that path doesn't resolve inside the crate itself. Added extern crate self as is_empty; for that. There's now a test that derives through a qualified path with the trait not imported and a decoy IsEmpty in scope, which fails without the fix.

Dropped the re-export and pointed chainstate-test-suite at is-empty directly. Doc comment now says it's plain method lookup and calls out the fixed size array case.

On the rebase, the branch was already on master with #2102 in it, but it had drifted a couple of commits so it's up to date now.

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.

Introduce IsEmpty derive trait

2 participants