Pre-RFC: `Tap` and `TapMut` traits

Hi all,

Apologies if this isn't the correct venue for posting this. If so, please just let me know!

I'd like to propose two new, potentially blanket traits for/in the Rust standard library: Tap and TapMut.

For those familiar with Ruby, the concept behind these traits is identical to Ruby's Object#tap:

Yields self to the block, and then returns self. The primary purpose of this method is to “tap into” a method chain, in order to perform operations on intermediate results within the chain.

Here's what that looks like in Ruby:

(1..10)                  .tap {|x| puts "original: #{x}" }
  .to_a                  .tap {|x| puts "array:    #{x}" }
  .select {|x| x.even? } .tap {|x| puts "evens:    #{x}" }
  .map {|x| x*x }        .tap {|x| puts "squares:  #{x}" }

Since Rust doesn't have default mutability the way Ruby does, the Tap trait's primary purpose would be allowing users to interpose e.g. logging into a long chain of methods, without having to break out into a temporary variable or other (less explicit) binding context. TapMut would provide the mutable counterpart, allowing a user to interpose modifications to an object.

By way of example, here's how a Tap trait could be used to debug a fluent-style API:

let x = Something::new()
  .foo()
  .tap(|thing| log!(thing))
  .bar()
  .tap(|thing| log!(thing))
  .baz()

Here's my naive implementation of Tap:

pub trait Tap {
    fn tap<F>(&self, f: F) -> &Self
    where
        F: Fn(&Self) -> (),
    {
        f(&self);
        &self
    }
}

I suspect TapMut would be nearly identical, but with FnMut instead.

I appreciate any thoughts on this! This is also my first attempt at writing a (pre) RFC for Rust, so I'd appreciate any pointers on style or presentation.

7 Likes

Why does this need to be in std, rather than a crate? Does it facilitate any ecosystem-wide interfaces?

I just noticed it already is a crate.

13 Likes

I don’t think these need to be traits, I think they would be mostly useful as new additions to Iterator. Just speaking for myself I would like to see these added though, I already have seen people (ab)use map for this purpose, it would be nice if this was its own operation.

4 Likes

For Iterator there’s already inspect exactly for this purpose.

12 Likes

TIL! Thanks :slightly_smiling_face:

This seems useful. (The following is intended as friendly advice). A pre-rfc and rfc would make more sense after you've gathered some real-world experience with this idea implemented into Rust - using the tap crate or any other implementation.

We can also understand skysch's question Why does this need to be in std, rather than a crate? maybe not as a dismissal, but a real question that an RFC would have to answer. :slightly_smiling_face:

This interface sort of made me think of dbg!() which is also made to be able to be interposed, and maybe that's a good thing - the community likes these kinds of things.

6 Likes

FWIW, the one I find very useful is .tap_mut(), actually, to get more fine-grained control over mutability of an item just while constructing it (à la .also in Kotlin):

let ref target_dir =
    PathBuf::from(env::var_os("CARGO_MANIFEST_DIR")?).tap_mut(|p| {
        p.push("target"); // assume a simple case for the example
        if for_wasm() {
            p.push("wasm32-unknown-unknown");
        }
    })
;
let debug_dir;
#[cfg(mistake)] {
    debug_dir = target_dir.push("debug"); // Error, only `&` access
}
#[cfg(not(mistake))] {
    debug_dir = target_dir.join("debug"); // OK
}
let release_dir = target_dir.join("release");
println!("{:?}\n{:?}", debug_dir, release_dir);
other_stuff(target_dir);
  • On a tangentially related note, I'd love a PathBuf -> PathBuf "append a path segment" pipelineable operation: .join(…).join(…).join(…) is just so sad.

Thanks for the thoughtful responses thus far!

To address a few:

@skysch:

Why does this need to be in std, rather than a crate? Does it facilitate any ecosystem-wide interfaces?

I don't think it needs to be in std, but that it's a generally useful feature (particularly when writing and/or debugging fluent APIs, which both std and large chunks of the crate ecosystem adhere to. I also think it's a sufficiently generic concept that already has siblings in std (dbg!, as @bluss mentioned), which makes it a decent candidate for inclusion.

@XAMPPRocky

I don’t think these need to be traits, I think they would be mostly useful as new additions to Iterator .

It's possible that I'm misunderstanding, but I think that Tap and TapMut would be useful outside of an Iterator context -- there are a number of APIs that are fluent-style that would benefit from being tap-able that aren't themselves impl Iterator and don't make sense to be (like std::process::Command). @jdahlstrom brings up inspect which is indeed exactly this for Iterator, but to use inspect for T where T: !Iterator you'd either need to do the Option<T> trick or some other one. Those feel a little clunky to me, which is why I initially proposed an entirely separate trait. But again, very possible that I'm misunderstanding :slightly_smiling_face:

@bluss:

A pre-rfc and rfc would make more sense after you've gathered some real-world experience with this idea implemented into Rust - using the tap crate or any other implementation.

We can also understand skysch's question Why does this need to be in std, rather than a crate? maybe not as a dismissal, but a real question that an RFC would have to answer.

Thank you, and thanks for the framing! I'll dedicate some time to developing real-world samples for this idea, and update this thread with them. I've tried to answer @skysch's question haphazardly above, but it certainly merits a less handwave-y justification.

2 Likes

The interesting part is that there's three kinds of receivers (ignoring arbitrary ones), and thus three styles of fluent APIs in use today.

Specifically, all of f(self, ...) -> Self, f(&self, ...) -> &Self, and f(&mut self, ...) -> &mut Self are used for different purposes.

If the final consumer wants to take ownership (common in builder APIs), then intervening steps have to take self by value to chain to the consumer. Typesafe builders also need to change the type through the building methods. The way self is passed through usually is decided by how the end consumer wants to consume the value.

If tap upgrades to std, I'd want to see either a design that manages to just offer tap(self, impl FnOnce(&mut Self)) -> Self rather than also tap_ref(&self, impl FnOnce(&Self)) -> &Self and tap_mut(&mut self, impl FnOnce(&mut Self)) -> &mut Self, or a strong argument why the former isn't sufficient. Always just using tap is better than using tap_ref and tap_mut for by[-mut]-ref chains.

(Also, I'd just like to see a note as to whether this is expected to mostly supersede let val = { let val = /*...*/; /*...*/; val }; blocks.)

Then there's the question of whether people would import the trait, or just open code it. Importing the trait from std is definitely lower burden than importing a trait from a crate, but I doubt any blanket extension trait would be added to the prelude, and tap is on the same line as dbg! where its utility comes from always being there, rather than something you have to ask for, since just open coding it is not all that expensive.

5 Likes

I think this should be called out explicitly - @woodruffw, the function should be FnOnce, because tap or tap_mut will only call the function once.

1 Like

Open coding it might get easier in future, too. Not saying we will, but if .match exists then .tap(x => foo(x)) is .match { x => { foo(x); x } } -- and tap_move is even easier.

5 Likes

Indeed, it doesn't. On the other hand, small things, like this, are less likely to be used if they require fetching another dependency.

Btw. I would rather rename tap to inspect and tap_mut to just tap.

I used to write lots of Kotlin code and made heavy use of the scope functions also and apply. The also function is equivalent to the proposed tap function. The apply function returns the result of the given closure:

    fn apply<F, R>(self, f: F) -> R
        where
            F: FnOnce(Self) -> R;

In Rust projects I usually end up implementing similar functions manually. I think both would be useful additions to the standard library, as people usually wouldn't want to add a crate dependency just for those functions.

1 Like