Skip to content

Replace (usize, Option<usize>) with custom type SizeHint and eliminate try_size_hint(). #218

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

kpreid
Copy link
Contributor

@kpreid kpreid commented Apr 10, 2025

The SizeHint type provides these advantages:

  • Easier to construct by composition, using + and | or methods.
  • More self-explanatory.
  • Distinguishes between “unknown size” and “known to be unbounded” to aid debugging.

Since this is a breaking change to the trait, this PR also makes the closely related change of combining try_size_hint() and size_hint() into a single fallible function. This will make it easier to correctly implement the trait.

Also, while I was touching the macro code, I made all the crate paths absolute (::arbitrary), as they should be for the maximum compatibility.

Fixes #201.
Part of #217.

Copy link
Member

@fitzgen fitzgen left a comment

Choose a reason for hiding this comment

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

Thanks for whipping up this PR!

I think we still have some design questions to answer before we commit to a particular approach. See inline comments below.

src/size_hint.rs Outdated
Comment on lines 90 to 105
/// When the depth is not too deep, calls `f` with `depth + 1` to calculate the
/// size hint.
///
/// Otherwise, returns an error.
///
/// This should be used when implementing [`Arbitrary::size_hint()`]
/// for generic types.
pub fn recursion_guard(
depth: usize,
f: impl FnOnce(usize) -> Result<Self, crate::MaxRecursionReached>,
) -> Result<Self, crate::MaxRecursionReached> {
if depth > MAX_DEPTH {
Err(crate::MaxRecursionReached {})
} else {
f(depth + 1)
}
Copy link
Member

Choose a reason for hiding this comment

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

Let's switch from depth to fuel, from incrementing to decrementing, and from depth > MAX_DEPTH to fuel == 0.

I think we will may also want to preserve the fuel count across size_hint and recursion_guard invocations such that if we are given fuel = 100 that doesn't mean "process type trees of up to depth 100" but instead "call up to 100 size_hint/recursion_guard methods". The latter is a better overall limit on compute spent on size hints. The former isn't very useful because a depth of 10 already gets to binary trees of size 1024 but also isn't very deep at all for non-pathological/non-exponential cases.

This probably involves defining and threading a new context type through size_hint calls and removing public constructors for SizeHint and replacing them with methods on the context, something like

pub struct SizeHintContext {
    fuel: usize,
}

impl SizeHintContext {
    pub fn new(fuel: usize) -> Self {
        Self { fuel }
    }

    fn consume_fuel(&mut self) -> Result<()> {
        if *self.fuel == 0 {
            return Err(());
        }
        *self.fuel -= 1;
        Ok(()) 
    }
    
    pub fn exactly(&mut self, size: usize) -> Result<SizeHint> {
        self.consume_fuel()?;
        Ok(SizeHint {
            lower: size,
            upper: Upper::Bounded(size),
        })
    }

    pub fn at_least(&mut self, size: usize) -> Result<SizeHint> {
        self.consume_fuel()?;
        Ok(SizeHint {
            lower: size,
            upper: Upper::Unbounded,
        })
    }
    
    // Other `SizeHint` constructors and combinators...
}

// ...

pub trait Arbitrary {
    fn size_hint(context: &mut SizeHintContext) -> Result<SizeHint> {
        SizeHint {
            lower: 0,
            upper: Upper::Unbounded,
        }
    }
}

It might even make sense to actually put the SizeHint inside the SizeHintContext and just have methods that mutate the size hint being built, rather than returning new size hints that we then need to combine. This would further disincentivize constructing a new SizeHintBuilder in the middle of a size_hint method implementation, which would defeat the purpose of having fuel in the first place. Doing it this way would also allow extracting the half-built SizeHint and getting at least an idea of the lower bound, even when we run out of fuel. (Maybe the consume_fuel method should set upper to unbounded if it returns an error?)

This approach effectively turns the context into a builder:

pub struct SizeHintBuilder {
    fuel: usize,
    lower: usize,
    upper: Upper,
}

impl SizeHintBuilder {
    pub fn new(fuel: usize) -> Self {
        Self {
            fuel,
            lower: 0,
            upper: Upper::Bounded(0),
        }
    }
    
    fn consume_fuel(&mut self) -> Result<()> {
        if *self.fuel == 0 {
            return Err(crate::Error::SizeHintOutOfFuel);
        }
        *self.fuel -= 1;
        Ok(()) 
    }
    
    pub fn finish(self) -> SizeHint {
        SizeHint {
            lower: self.lower,
            upper: self.upper,
        }
    }
    
    pub fn exactly(&mut self, size: usize) -> Result<()> {
        self.consume_fuel()?;
        self.lower = self.lower.checked_add(size).ok_or(())?
        self.upper += Upper::Bounded(size);
        Ok(())
    }

    pub fn at_least(&mut self, size: usize) -> Result<()> {
        self.consume_fuel()?;
        self.lower = self.lower.checked_add(size).ok_or(())?
        self.upper += Upper::Unbounded;
        Ok(())
    }
    
    pub fn unknown(&mut self) -> Result<()> {
        self.consume_fuel()?;
        self.upper += Upper::Unknown;
        Ok(())
    }
    
    // Other `SizeHint` constructors and combinators...
}

pub trait Arbitrary {
    fn size_hint(builder: &mut SizeHintBuilder) -> Result<()> {
        builder.unknown()
    }
}

What do other arbitrary maintainers think of the above approaches? I'm kind of partial to the builder-style API myself.

cc @Manishearth @nagisa

Copy link
Member

Choose a reason for hiding this comment

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

I guess the and_all and or_all combinators get a little trickier with the builder style API rather than the context-style API. We would either need to create sub-builders, which makes it easy to defeat the purpose of a single shared fuel value, or we would have to do some nasty generics magic which makes the API impenetrable. Or some other funky API to thread the fuel through.

This is making me lean back towards the context-style approach.

Copy link
Member

Choose a reason for hiding this comment

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

The builder seems fine, I'm worried about too much additional computation in what is essentially pre-fuzz infrastructure

Copy link
Contributor Author

@kpreid kpreid Apr 15, 2025

Choose a reason for hiding this comment

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

I guess the and_all and or_all combinators get a little trickier with the builder style API

Even or() is difficult to do that way because you need to take the minimum of multiple independent sums.

That said, I think it would make sense to track fuel in, not a “builder” (makes one output value) but a “factory” (makes many); methods of the factory are constructors of SizeHint values (or error). I will see about trying that out.

Copy link
Member

Choose a reason for hiding this comment

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

@Manishearth

I'm worried about too much additional computation in what is essentially pre-fuzz infrastructure

Nothing here should be any more computationally complex than what we have today, it is all just hiding implementation details in non-public struct fields for the most part.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

you couldn't construct a SizeHint without checking/consuming fuel,

Something I’d like to be able to offer someday — though there is no provision in the current trait/macro for doing so in practice — is the possibility of defining size hints for finite structures as constants. This would mean that infinite recursions would automatically be caught at compile time by the const evaluation limit, and run time doesn't need to compute an essentially constant value every time. That world can be mixed with the get() world, but I don't think it can be mixed with the "all construction consumes fuel" world (unless there is also some kind of ContextWithInfiniteFuel for static use).

That said, I do see your point and I'll switch it over if you still object. However, I don't think it is necessary to go back to using Result to do so.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

(unless there is also some kind of ContextWithInfiniteFuel for static use).

Actually, no reason that would need to be separate. And a macro could help support this hypothetical const use case. All right. I still think that interposing on recursions only is cleaner, but ease of getting it right is the most important thing.

Copy link
Member

Choose a reason for hiding this comment

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

Something I’d like to be able to offer someday — though there is no provision in the current trait/macro for doing so in practice — is the possibility of defining size hints for finite structures as constants. This would mean that infinite recursions would automatically be caught at compile time by the const evaluation limit, and run time doesn't need to compute an essentially constant value every time. That world can be mixed with the get() world, but I don't think it can be mixed with the "all construction consumes fuel" world (unless there is also some kind of ContextWithInfiniteFuel for static use).

This would be nice, but I wouldn't bend over backwards for it. In practice, LLVM should be able to inline and const-fold away 99% of size_hint implementations, so the benefit is really just for people writing size_hints by hand (since we can presumably always do the right thing in the derive macro). All that is to say, there is also value in having a simple, easy-to-understand-at-a-glance API and sometimes that can outweigh fitting things into the static type system. Basically the same reason why we don't always use branded-lifetimes tricks (similar to the ghost-cell crate) every time we index into slices, and accept that bounds checks will happen at runtime (modulo llvm optimizing them away), even though we could use a convoluted API to prove safety at compile time and which would allow us to omit the runtime bounds checks.

Copy link
Member

Choose a reason for hiding this comment

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

However, I don't think it is necessary to go back to using Result to do so.

Ah, this is a good point: the helper function I wrote is basically context.get::<T>() in the new PR iteration. I guess the one thing that Results allow for is easily determining whether the result of a size_hint call is partial or not, but given that you've put an out_of_fuel: bool field inside SizeHint that can still be easily exposed to users (most of whom likely will not care, so making it less in-your-face API-wise as a method on SizeHint that userc can choose to query, rather than a variant they are forced to match on before doing what they really want to do, is probably a good trade off).

Copy link
Member

Choose a reason for hiding this comment

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

I still think that interposing on recursions only is cleaner, but ease of getting it right is the most important thing.

Yeah, to be clear avoiding fuel on leaf types would be nice, but I think preventing footguns is definitely the more important property here.

src/size_hint.rs Outdated
Comment on lines 26 to 35
/// The size is known to be unbounded (or overflow [`usize`]).
Unbounded,
/// The size is unknown because a more specific size hint was not given.
/// This is functionally identical to `Unbounded` but prints differently.
Unknown,
Copy link
Member

Choose a reason for hiding this comment

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

I'm not convinced that there is a meaningful difference between these variants. Just having a different Debug print doesn't really seem worth it to me.

Copy link
Contributor Author

@kpreid kpreid Apr 15, 2025

Choose a reason for hiding this comment

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

I included this feature precisely because, if it had existed, it would have helped me troubleshoot a missing size hint method.

…size>)>`.

Introducing the `SizeHint` type provides these advantages:

* Easier to construct by composition, using `+` and `|` or methods.
* More self-explanatory.
* Distinguishes between “unknown size” and “known to be unbounded”
  to aid debugging.

The `Context` instead of `depth` provides these advantages:

* Fully bounded computation with no dependence on the recursion
  breadth.
* Elimination of `Result`, making `size_hint()` implementors simpler
  and allowing reporting of partially-known lower bounds rather than
  discarding all information on error.

Since there are no longer any `Result`s, `try_size_hint()` is of course
gone, making it easier to correctly implement the trait. But that could
also have been done by adding the `Result` return to `size_hint()` if we
were keeping it.

Also, while I was touching the macro code, I made all the crate paths
absolute (`::arbitrary`), as they should be for the maximum compatibility.
Copy link
Member

@fitzgen fitzgen left a comment

Choose a reason for hiding this comment

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

Thanks for your patience and sticking with this while we work out the design kinks!

I think we are now at a place where we agree what the design should look like (no Results; SizeHint construction consumes fuel, even for leaf types) so if you update this PR accordingly, I can take a closer look and provide the hopefully final review.

Thanks again!

Comment on lines +316 to +318
/// Consumes 1 unit of fuel if available, and returns whether that fuel was available.
#[inline(never)]
fn consume_fuel(&self) -> bool {
Copy link
Member

Choose a reason for hiding this comment

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

Is there a reason you made this inline(never)? I would expect that we would want to make it inline instead, so that LLVM can fully inline and const-fold away size_hint implementations for small, non-recursive types.

Comment on lines +306 to +314
/// Returns [`T::size_hint()`][Arbitrary::size_hint], and consumes 1 unit of fuel too.
#[inline]
pub fn get<'a, T: Arbitrary<'a>>(&self) -> SizeHint {
if self.consume_fuel() {
T::size_hint(self)
} else {
SizeHint::OUT_OF_FUEL
}
}
Copy link
Member

Choose a reason for hiding this comment

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

Two nitpicks:

  1. I don't think we should make guarantees about how much fuel any particular method consumes. When describing what fuel is (say on the Context::new constructor), we can give rough estimates, but I think we want to keep it somewhat fuzzy so that we can adjust if necessary. The important part is that it provides a finite bound to size-hint computation, not exactly what that bound is.
  2. As discussed in thread, we shouldn't actually need this method anymore, once SizeHint construction (even for leaf types) consumes fuel. Users can just call the trait methods directly instead.

Comment on lines +103 to +110
/// Take the sum of the `self` and `rhs` size hints.
///
/// Use this when an [`Arbitrary::arbitrary()`] implementation is going to consume both
/// `self` bytes and `rhs` bytes, such as for the fields of a `struct`.
///
/// This operation can also be written as the `+` operator.
#[inline]
pub const fn and(self, rhs: Self) -> Self {
Copy link
Member

Choose a reason for hiding this comment

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

What do you think about moving all the and/and_all/or/or_all/repeat combinators to methods on Context? That way we retain the flexibility to make them consume fuel in the future, if that ever becomes necessary.

/// Also contains some meta-information about the size hint as a whole.
upper: Upper,

out_of_fuel: bool,
Copy link
Member

Choose a reason for hiding this comment

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

I think we will want a method on SizeHint, and probably Context too, for checking whether we are out of fuel. I didn't see these below; apologies if these exist and I just missed it in my skim.

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.

Consider a custom type for easier size hints
3 participants