Skip to content

Design note: Defaults are one way to say the same thing

Herb Sutter edited this page Oct 9, 2023 · 7 revisions

Q: Isn't allowing syntactic defaults providing more than one way to say the same thing?

A: No, not if they simply allow omitting parts you aren't currently using.

In Cpp2 I work hard to avoid providing two ways to spell the same thing.

Well-designed defaults that allow omitting unused parts of a general syntax are not two ways to say the same thing... they are the same One Way, but you aren't forced to mention the parts you're not currently using.

Further, they actively help avoid pressure to proliferate different ways of saying a thing by providing a single general way that's convenient in many places.

Example: Cpp2's function syntax works well from big generic functions, down to tiny lambdas with captures, down to plain old blocks and statements... using only one general syntax

From named functions, to lambdas, to parameterized blocks/statements, to ordinary blocks/statements

Consider these cases, all of which are supported:

f:(x: int = init) = { ... }     // x is a parameter to the function
f:(x: int = init) = statement;  // same, { } is implicit

 :(x: int = init) = { ... }     // x is a parameter to the lambda
 :(x: int = init) = statement;  // same, { } is implicit

  (x: int = init)   { ... }     // x is a parameter to the block
  (x: int = init)   statement;  // same, { } is implicit

                    { ... }     // an ordinary block
                    statement;  // same, { } is implicit

If we read those top to bottom, we see how the general function syntax just lets us omit more and more syntax if we aren't currently using it:

  • declaring an anonymous function is exactly the same as any function, just has no name (and still has : because we're declaring something new);
  • a parameterized block or statement is exactly the same as a function used in just this one place, so it just drops : since we're not declaring a separate entity (and drop = too because we're also not setting its value); and
  • an ordinary block or statement is exactly the same but with no parameter list, so it just omits the parameter list (and the ordinary block/statement syntax just falls out).

And if we read them bottom to top, we see how we can just add more and more power by opting into using it:

  • any ordinary block or statement can optionally have parameters...
  • ... and can be wrapped up to be called repeatedly and passed around...
  • ... and can be given a name.

Even those last two lines show an existing default: It is already true in today's C and C++ that { statement; } and statement; have identical meaning (except for macro effects), the braces are just optional and we nearly never bother to write them around individual statements (except in today's C and C++ branch bodies to guard against macro effects, which Cpp2 eliminates as a problem).

More about block/statement parameters

The third case above shows how a block or statement parameter list does the same work as “let” variables in other languages, but without a “let” keyword.

This subsumes all the special-cased Cpp1 loop/branch scope variables we have today. And it does it more generally than in Cpp1 today, because you can declare data flow direction (and const is the default which is right), and you can declare multiple parameters easily which you can't currently do with the Cpp1 loop/branch scope variables. Consider these examples:

//  Cpp2                               // C/C++ equivalent
//  (default 'in' == const)            // (& since which version)

(     x:int = f()) if x>1              // C++17: if (int x = f(); x>1)
                                           // note x is available inside the else too

(     x:int = f()) inspect x           // C++17: switch (int x = f(); x)

(copy x:int = f()) for x>1 next x--    // K&R C: for (int x = f(); x>1; --x)

(     x:int = f()) for range do (e)    // C++20: for (int x = f(); auto const& e : range)

(     x:int = f(), copy i:=0)          // C++20: { int x=f(); for (auto i = 0;
          for range next i++ do (e)    //              auto const& e : range) { ++i;

(copy x:int = f()) while x>1           // not yet allowed for 'while' in C++20

(copy x:int = f()) do { } while x>1    // not yet allowed for 'do' in C++20

(copy x:int = f()) try                 // not yet allowed for 'try' in C++20
                                           // note x would be available inside the catch too

Note: try has not yet been implemented in cppfront

So this now works, pasting from test case pure2-statement-scope-parameters.cpp2:

main: (args) = 
{
    local_int := 42;
 
    //  'in' statement scope variable
    // declares read-only access to local_int via i
    (i := local_int) for args do (arg) {
        std::cout << i << "\n";       // prints 42
    }
 
    //  'inout' statement scope variable
    // declares read-write access to local_int via i
    (inout i := local_int) {
        i++;
    }
    std::cout << local_int << "\n";   // prints 43
}

Note that block parameters enable us to use the same declarative data-flow for local statements and blocks as for functions: Above, we declare a block (a statement, in this case a single loop, is implicitly treated as a block) that is read-only with respect to the local variable, and declare another to be read-write with respect to that variable. Being able to declare data flow is important for writing correct and safe code.

More design rationale

For example, a C++ function with a default parameter, like int f(int i, int j = 0), can be called with f(1,0), but it can equivalently be called as f(1)... but it's still just one function, and one way to call it. At the call site we just aren't forced to spell out the part where we're happy with the default (and we still can spell it out if we want).

Similarly, for a C++ class class C { private: int i; ... };, we can equally omit private: and say class C { int i; ... };. There's still just one class syntax, but we get to not mention defaults if we're happy with them (and we still can spell it out if we want).

To me, allowing a generic function f:(i:_) -> _ = { return i+1; } to be spelled f:(i) i+1; is like that... there's only one way to spell it, but you get to omit parts where you're happy with the defaults. And that's especially useful when writing functions at expression scope (aka lambdas), such as examples like these:

std::for_each(first, last, :(x) std::cout << x;);

std::transform(x.begin(), x.end(), y.begin(), :(x) x+1;);

location_of_value = std::find_if(x.begin(), x.end(), :(x) x == value$;);

There seems to be demand for this, because we've had many C++ proposals for such a terse lambda syntax. For example:

but none of those (or at least not their terse parts) have been accepted for the standard yet. So I'm trying to help satisfy a need other people have identified and explore what it would look like to fill it, to gather evidence and experience for whether and how it can be done in C++ for all C++ programmers.