Reader Q&A: What’s the best way to pass an istream parameter?

Here’s a super simple question: “How do I write a parameter that accepts any non-const std::istream argument? I just want an istream I can read from.” (This question isn’t limited to streams, but includes any similar type you have to modify/traverse to use.) Hopefully the answer will be super simple, too! So, before reading further: … Continue reading Reader Q&A: What’s the best way to pass an istream parameter? →

Jan 27, 2025 - 01:05
 0
Reader Q&A: What’s the best way to pass an istream parameter?

Here’s a super simple question: “How do I write a parameter that accepts any non-const std::istream argument? I just want an istream I can read from.” (This question isn’t limited to streams, but includes any similar type you have to modify/traverse to use.)

Hopefully the answer will be super simple, too! So, before reading further: What would be your answer? Consider both correctness and usability convenience for calling code.


OK, have you got an answer in mind? Here we go…

  • Spoiler summary:

Pre-C++11 answer

I recently received this question in email from my friend Christopher Nelson. Christopher wrote, lightly edited (and adding an “Option 0” label):

I have a function that reads from a stream:

// Call this Option 0: A single function

void add_from_stream( std::istream &s );  // A

Pause a moment — is that the answer you came up with too? It’s definitely the natural answer, the only reasonable pre-C++11 answer, and simple and clean…

Usability issue, and C++11-26 answers

… But, as Christopher points out, it does have a small usability issue, which leads to his question:

Testing code may create a temporary [rvalue] stream, and it would be nice to not have to make it a named variable [lvalue], but this doesn’t work with just the above function:

add_from_stream( std::stringstream(some_string_data) );
    // ERROR, can't pass rvalue to A's parameter

So I want to add an overload that takes an rvalue reference, like this:

void add_from_stream( std::istream &&s );  // B
    // now the above call would work and call B

That nicely sets up the motivation to overload the function. [1]

The core of the emailed question is: How do we implement functions A and B to avoid duplication?

The logic is exactly the same, so I just want one function to forward to another.

Would it be better to do this:

//  Option 1: Make B do the work, and have A call B

void add_from_stream( std::istream &&s ) {  // B
    // do work.
}

void add_from_stream( std::istream &s ) {  // A
    add_from_stream( std::move(s) );
}

Or this:

//  Option 2: Have A do the work, and have B call A

void add_from_stream( std::istream &s ) {  // A
    // do work.
}

void add_from_stream( std::istream &&s ) {  // B
    add_from_stream( s );
}

They both seem to work, and the compiler doesn’t complain.

It’s true that both options “work” in the sense that they compile. But, as Christopher knows, there’s more to correctness than “the compiler doesn’t complain”!

Before reading further, pause and consider… how would you answer? What are the pros and cons of each option? What surprises or pitfalls might they create? Are there any other approaches you might consider?


Christopher’s email ended by actually giving some good answers, but with uncertainty:

I think [… … …] [But] I’ve been surprised lately by some experiments with rvalue references. So I thought I would check with you.

I think it’s telling that (a) he actually does have a good intuition and answer about this, but (b) rvalue references are subtle enough that he’s uncertain about it and second-guessing his correct C++ knowledge. — Perhaps you can relate, if you’ve ever said, “I’m pretty sure the answer is X, but this part of C++ is so complex that I’m not sure of myself.”

So let’s dig in..

Option 1: Have & call && (bad)

Christopher wrote:

I think the first option looks weird, and I think it may possibly have weird side-effects.

Bingo! Correct.

If Option 1 feels “weird” to you too, that’s a great reaction. Here it is again for reference, with an extra comment:

//  Option 1: Make B do the work, and have A call B

void add_from_stream( std::istream &&s ) {  // B
    // do work.
}

void add_from_stream( std::istream &s ) {  // A
    add_from_stream( std::move(s) );  // <-- threat of piracy
}

In Option 1, function A is taking a modifiable argument and unconditionally calling std::move on it to pass it to B. Remember that writing std::move doesn’t move, it’s just a cast that makes its target a candidate to be moved from, which means a candidate to have its entire useful state stolen and leaving the object ’empty.’ Granted, in this case as long as function B doesn’t actually move from the argument, everything may seem fine because, wink wink, we happen to know we’re not actually going to steal the argument’s state. But it’s still generally questionable behavior to go around calling std::move on other people’s objects, without even a hint in your API that you’re going to do that to them! That’s like pranking random strangers by tugging on their backpack zippers and then saying “just kidding, I didn’t actually steal anything this time!”… it’s not socially acceptable, and even though you’re technically innocent it can still lead to getting a broken nose.

So avoid this shortcut; it sets a bad example for young impressionable programmers, and it can be brittle under maintenance if the code changes so that the argument could actually get moved from and its state stolen. Worst/best of all, I hope you can’t even check that code in, because it violates three C++ Core Guidelines rules in ES.56 and F.18, which makes it a pre-merge misdemeanor in jurisdictions that enforce the Guidelines as a checkin gate (and I hope your company does!).

Function A violates this rule (essentially, ‘only std::move from rvalue references’):

  • ES.56: Flag when std::move is applied to other than an rvalue reference to non-const.

Function B violates these rules:

  • F.18: Flag all X&& parameters (where X is not a template type parameter name) where the function body uses them without std::move.
  • ES.56: Flag functions taking an S&& parameter if there is no const S& overload to take care of lvalues.

It’s bad enough that we already have to teach that std::move is heavily overused (for example, C++ Core Guidelines F.48). Creating still more over-uses is under-helpful.

Option 2: Have && call & (better)

For Option 2, Christopher notes:

I imagine that the second one works because, as a parameter the rvalue reference is no longer a pointer to a prvalue, so it can be converted to an lvalue reference. Whereas, in the direct call site it is not.

Bingo! Yes, that’s the idea.

Recall Option 2, and here “function argument” mean the thing the caller passes to the parameter, and “function parameter” means its internal name and type inside the function scope:

//  Option 2: Have A do the work, and have B call A

void add_from_stream( std::istream &s ) {  // A
    // do work.
}

void add_from_stream( std::istream &&s ) {  // B
    add_from_stream( s );
}

Note that function B only accepts rvalue arguments (such as temporary objects)… but once we’re inside the body of B the named parameter is now an lvalue, because it has a name! Why? Here’s a nice explanation from Microsoft Learn:

Functions that take an rvalue reference as a parameter treat the parameter as an lvalue in the body of the function. The compiler treats a named rvalue reference as an lvalue. It’s because a named object can be referenced by several parts of a program. It’s dangerous to allow multiple parts of a program to modify or remove resources from that object. 

So the argument is an rvalue (at the call site), but binding it to a parameter name makes the compiler treat it as an lvalue (inside the function)… so now we can just call A directly, no fuss no muss. Conceptually, function B is implicitly doing the job of turning the argument into an lvalue, and so serves its intended purpose of expressing, “hey there, this overload set accepts rvalues too.”

And that’s… fine.

It’s still not “ideal,” though, for two reasons. First, astute readers will have noticed that this only addresses two of the three C++ Core Guidelines violations mentioned above… the new function B still violates this rule:

  • ES.56: Flag functions taking an S&& parameter if there is no const S& overload to take care of lvalues.

The reason this rule exists is because functions that take rvalue references are supposed to be used as overloads with const& to optimize “in”-only parameters. We could shut up the stupid checker eliminate this violation warning by adding such an overload, and mark it =delete since there’s no other real reason for it to exist (consider: how would one use a const stream?). So to get past a C++ Core Guidelines checker we would actually write this:

//  Option 2, extended: To be C++ Core Guidelines-clean

void add_from_stream( std::istream &s ) {  // A
    // do work.
}

void add_from_stream( std::istream &&s ) {  // B
    add_from_stream( s );
}

void add_from_stream( std::istream const& s ) = delete;  // C

The second reason this isn’t ideal is that having to write an overload set of two or three functions is annoying at best, because we’re just trying to express something that seems awfully simple: “I want a parameter that accepts any non-const std::istream argument“… that shouldn’t be hard, should it?

Can we do better? Yes, we can, because those aren’t the only two options.

Option 3: Write a single function using a forwarding reference (a waypoint to the ideal)

In C++, without using overloading, yes we can write a parameter that accepts both lvalues and rvalues. There are exactly three options:

  • Pass by value (“in+copy”)… but for streams this is impossible, because streams aren’t copyable.
  • Pass by const& (“in”)… but for useful streams this is impossible, because streams have to be non-const to be read from (reading modifies the stream’s position).
  • Pass by T&& forwarding reference … … … ?

“Exclude the impossible and what is left, however improbable, must be the truth.”A.C. Doyle, “The Fate of the Evangeline,” 1885.

Today, the C++11-26 truth is that the way to express a parameter that can take any istream argument (both lvalues and rvalues), without using overloading, is to use a forwarding reference: T&& where T is a template parameter type…

Wait! You there, put down the pitchfork. Hear me out.

Yes, this is complex in three ways…

  • It means writing a template. That has the drawback that the definition must be visible to call sites (we can’t ship a separately compiled function implementation, except to the extent C++20 modules enable).
  • We don’t actually want a parameter (and argument) to be just any type, so we also ought to constrain it to the type we want, std::istream.
  • Part of the forwarding parameter pattern is to remember to correctly std::forward the parameter in the body of the function. (See C++ Core Guidelines F.19.)

Here’s the basic pattern:

//  Option 3(a): Single function, takes lvalue and rvalue streams

template <typename S>
void add_from_stream( S&& s )
    requires std::is_same_v<S, std::istream>
{
    // do work + remember to use s as "std::forward<S>(s)"
}

For reasons discussed in Barry Revzin’s excellent current proposal paper P2481 (more on that in a moment), we actually want to allow arguments that are of derived type or convertible type, so instead of is_same we would actually prefer is_convertible:

//  Option 3(b): Single function, takes lvalue and rvalue streams

template <typename S>
void add_from_stream( S&& s )
    requires std::is_convertible_v<S, std::istream const&>
{
    // do work + remember to use s as "std::forward<S>(s)"
}

“Seriously!?” you might be thinking. “There’s no way you’re going to teach mainstream C++ programmers to do that all the time! Write a template? Write a requires clause?? And write std::forward<S>(s) with its pitfalls (don’t forget <S>)??? Madness.”

Yup, I know. You’re not wrong.

Which is why I (and Barry) believe this feature needs direct language support…

Post-C++26: Simple, elegant, clean

Remember I mentioned Barry Revzin’s current paper P2481? Its title is “Forwarding reference to specific type/template” and it proposes this exact feature.

And astute readers will recall that my Cpp2 syntax already has this generalized forward parameter passing style that also works for specific types, and I designed and implemented that feature in cppfront and demo’d it on stage at CppCon 2022 (1:26:28 in the video):

Screenshot from CppCon 2022 showing "forward" parameter passing

See Cpp2: Parameters for a summary of the Cpp2 approach. In a nutshell: You declare “what” you want to do with the parameter, and let the compiler do the mechanics of “how” to pass it that we write by hand today. Six options, one of which is forward, are enough to cover all kinds of parameters.

As you see in the accompanying CppCon 2022 screenshot, the forward parameters compiled to ordinary C++ that followed Option 3(a) above. A few days ago, I relaxed it to compile to Option 3(b) above instead, as Barry suggested and another Cpp2 user needed (thanks for the feedback!), so it now allows polymorphism and conversions too.

So in Cpp2 today, all you write is this:

// Works in Cpp2 today (my alternative experimental simpler C++ syntax)

add_from_stream: ( forward s: std::istream ) = {
    // do work.
}

… and cppfront turns that into the following C++ code that works fine today on GCC 11 and higher, Clang 12 and higher, MSVC 2019 and higher, and probably every other fairly recent C++ compiler:

auto add_from_stream(auto&& s) -> void
    requires (std::is_convertible_v<CPP2_TYPEOF(s), std::add_const_t<std::istream>&>)
{
    // do work + automatically adds "std::forward" to the last use of s
}

Write forward, and the compiler automates everything else for you:

  • Makes that parameter a generic auto&&.
  • Adds a requires clause to constrain it back to the specific type.
  • Automatically does std::forward correctly on every definite last use of the parameter.

It’s great to have this in Cpp2 as a proof of concept, but both Barry and I want to make this easy also in mainstream ISO C++ and are independently proposing that C++26/29 support the same feature, including that it would not need to be implicitly a template. Barry’s paper has been strongly encouraged, and I think the only remaining question seems to be the syntax (see the record of polls here), which could be something like one of these:

// Proposed for C++26/29 by Barry's paper and mine

// Possible syntaxes

void add_from_stream( forward std::istream s );

void add_from_stream( forward: std::istream s );

void add_from_stream( forward s: std::istream );

// or something else, but NOT "&&&" 
// -- happily the committee said "No!" to that

In Cpp2, forward parameters with a specific type have proven to be more useful than I originally expected, and that’s even though they compile down to a C++ template today… one benefit of adding this feature directly into the language is that a forwarding reference to a concrete type could be easily implemented as a non-template if it’s part of C++26/29.

My hope for near-future C++ is that the simple question “how do I pass any stream I can read from, even rvalues?” will have the simple answer “as a forward std::istream parameter.” This is how we can simplify C++ even by adding features: By generalizing things we already have (forwarding parameters, to work also for specific types) and enabling programmers to directly express their intent (say what you mean, and the language can help you). Then even though the language got more powerful, C++ code has become simpler.

[Updated to emphasize 2(b)] In the meantime, in today’s C++, Option 0 is legal and fine, and consider Option 2(b) if you want to accept rvalues. Today, Option 3 is the only direct (and cumbersome) way to write a function that takes a stream without having to write any overloads.

Thanks again to Christopher Nelson for this question!

Notes

[1] For simpler cases if you’re reading a homogenous sequence of the same type, you could try taking a std::ranges::istream_view. But as far as I know, that approach doesn’t help with general stream-reading examples.