Pre-RFC - Customizing `derive(Debug)`

This is a Pre-RFC for a new attribute which could be used to customise the behaviour of derive(Debug). I'm posting here mostly to get feedback on the usefulness/lack thereof of the proposed variants :slightly_smiling_face:

  • Feature Name: (derive_debug_attributes)
  • RFC PR: TODO
  • Rust Issue: TODO

Summary

Introduce a #[debug] attribute, used to control various aspects of the Debug derive macro.

#[derive(Debug)]
#[debug(transparent)]
struct NewType(Vec<u8>);

// dbg!(newtype) => "[1, 2, 3, 4]"

#[derive(Debug)]
struct SkipField<'a> {
  #[debug(skip)]
  context: &'a VeryLargeContextType,
  field: u8,
}
// dbg!(skipfield) => SkipField { field: 5 }

#[derive(Debug)]
struct FnStruct<F: Fn() -> u8> {
  #[debug(type)]
  f: F,
}
// dbg!(fnstruct) => FnStruct { f: crate::main::{{closure}} }

#[derive(Debug)]
struct Rename {
  #[debug(rename = "bar")]
  foo: u8,
}
// dbg!(rename) => Rename { bar: 0 }

Motivation

Allowing tweaks to the behaviour of derive(Debug) without requiring manual implementation or proc-macros reimplementing the bulk of the current behaviour.

Guide-level explanation

The #[default] attribute can be used to customize the behavior of the #[derive(Debug)] macro.

#[debug(transparent)]

This attribute may only be applied to a single-field struct, and simply uses that field's Debug implementation. This can be used, for example, to introduce a newtype that is displayed the same as the inner type.

#[debug(skip)]

This attribute, when applied to a field, skips it entirely when debug formatting.

#[debug(type)]

This attribute, when applied to a field, uses the std::any::type_name of the field rather than its value. This can be useful for structs containing a closure or function pointer.

#[debug(rename = "NAME")]

This attribute, when applied to a named field, simply displays the field using the provided NAME.

Reference-level explanation

A builtin-in attribute #[debug] is provided to the compiler, and may only be used on a type with #[derive(Debug)] applied.

TODO

Drawbacks

  • Adds complexity to the use of the Debug derive macro.
  • For skip and type to be more useful, types only used in fields marked as such should be exempt from the automatic derives placed on any generic types in the struct, and I'm uncertain if the debug attribute is the place to solve that (Perfect Derive)

Rationale and alternatives

  • Current approaches to solving issues with the automatic Debug derive are to implement by hand and use third-party derive macro crates
  • Implementing by hand leads to verbose, overlong code
  • Using a third party crate requires a lot of complexity, e.g. pulling in syn simply to create an implementation which does less
  • Both approaches do not benefit from the highly tweaked and optimised current derive code

Prior art

The derivative crate is the main inspiration for this, which provides custom configurable versions of several built-in derives, but has not been updated in some time.

The attribute naming is based on the #[default] attribute, used by the Default derive macro for enums.

Future possibilities

More variants of the #[debug] attribute could be introduced in order to allow further tweaking.

Other derive macros could receive similar attributes - being able to skip over a field would be applicable to Eq, Ord, Hash etc.

13 Likes

Another transformation I sometimes need to do in my Debug implementations is to convert from one type into another type to display. Something like #[debug(as_ref = Ty)] to have it map via an implementation of AsRef, or #[debug(into = Ty)] to use an implementation of Into (the latter requiring the field be Copy, since Into::into takes an owned value).

I would love to see this. I'm not sure if we want all four of these variants, but at least skip and transparent seem great, type seems trivial and potentially useful for when you don't want to fully skip something, and rename may be useful enough to be worth supporting.

One other important property: using skip or type on a field should mean derive(Debug) does not require a Debug impl for the type of that field.

Please file a libs ACP proposing this. I'd be happy to nominate it for the next libs-api meeting.

3 Likes

I'd like to see this have some way of saying what shouldn't be pulled in. Arguably, the first three of these apply to every single 3rd party derive on crates-io.

What is it about these ones specifically that are important enough to do? How would someone know if they should propose another thing that derive(Debug) could do?

2 Likes

skip and transparent are the ones I'm particularly commited to, I just thought about putting out some other ideas to see if there was any interest. I'll probably file an ACP starting with those, then possibly follow up later with others if it seems like a good idea.

The main criteria in my mind is that they should be 'trivial' to implement, and result in Debug either doing the same amount or less of work, for small tweaks. Extended functionality would be better left to a 3rd-party derive

skip seems the most valuable to me, because it makes derive(Debug) possible for a struct where one field doesn't implement Debug.

3 Likes

I don't like it, because I see no obvious bound to the number of such derive-customizing attributes. One can always propose something more. This complicates reading type definitions, and increases the risk of conflict between built-in attributes and attributes of custom derive macros.

I almost never care about the specifics of Debug implementation, and the status quo fits it perfectly: there is either an unobtrusive Debug token in the #[derive] list, or a separate implementation, which is easy to skip. If #[derive(Debug)] gets customization through attributes, then I'll have to wade through that irrelevant information every time I need to read a struct's definition. If the number of derive attributes grows, both for Debug and other built-in derives, this can become a major readability problem.

It's not like custom Debug derives are hard to write, they are just mildly annoying, and you don't need to do it that often anyway. An IDE could easily provide an intention to generate an impl Debug stub, which you'd just need to slightly fix by hand (e.g. remove unneded trait bounds or skipped fields).

The motivation why this couldn't be a library macro seems weak. syn is a bit of a problem, but a minor one for larger projects, which will pull it in anyway. Even smaller ones offten depend on syn, it's just too convenient (e.g. you're likely to still want to use thiserror or serde-deive).


If this one is accepted, I'd want it at least to use the #[Debug] syntax for customization attributes, rather than #[debug]. It would reduce the chance of collision with any other custom attributes, and make it more obvious that the attribute is part of #[derive(Debug)]'s customization, rather some separate macro attribute or a custom derive macro attribute.

2 Likes

If skip is added, it should probably be spelled #[skip(Debug)] and be applicable to Eq, Ord and other "field-wise derivable" traits as well.

19 Likes

TBH, I prefer this phrasing. It makes the proposal "add a common skip attribute" instead of "add a bunch of customization knobs".

Then #[skip(Hash)] works for fields not worth hashing, and the compiler can do things like deny if you derive(PartialEq, Hash) but have skip(PartialEq), since that means that the Hash implementation is just wrong.

16 Likes

I like the idea of the common skip attribute, and then a very limited #[Debug(transparent)] can decorate the entire struct.

This may address the "unbounded" concerns, by saying skip is it's own widely-applicable item label, while #[Debug(...)] affects the whole struct (and explicitly does not call out specific items). It should read well with the current spelling of #[repr(transparent)]

1 Like

VSCode with Rust-Analyzer already does this (using the Quick Fix on the following:

impl Debug for Test {}

I've used that quite a bit together with hex-encoding Vec<u8> so it is more compact.

Something like the following would be amazing to have, although as you said it's easy to implement instead of derive Debug and this does become hard to read with the closure variant, so it might be better suited for separate crates:

#[derive(Debug)]
struct MyStruct {
    #[Debug(encode = |a| a.encode_hex::<String>())]
    a: Vec<u8>,

    // Alternative (similar to #[serde(with = ...)], similar to JarredAllen's suggestion
    #[Debug(with = DifferentTypeImplementingDebug)]
    b: Vec<u8>,
}

My main issue with implementing a separate Debug is that it is really easy to add another field to a struct without updating the Debug impl.

There's a great way to handle that: pattern matching.

Rather than implementing Debug using

write!(f, "<{}, {}>", self.x, self.y)

you can do

let Self { x, y } = self;
write!(f, "<{x}, {x}>")

And then if you add another field, you'll get a compiler error.

(x and y become references, since self is.)

11 Likes

Minor nitpick: I assume you meant "<{x}, {y}>". The (semi-?)guaranteed absence of logic errors like this one might, however, also be considered to be an argument in favor of the proposal.

It would generate an unused bindings lint if you actually compile this bug, at least.

3 Likes

...which is another big advantage over using "<{self.x}, {self.x}>", even if field access like that was supported.

1 Like

It would be very easy for us to lint about any Debug impl that prints one field twice and misses printing another field. That seems sufficiently likely to be an error that it'd be reasonable to warn about and expect people to suppress the warning if they're building an unusual Debug impl.

3 Likes