Skip to content
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

Proposal: Block parameters #589

Closed
theunrepentantgeek opened this issue May 16, 2017 · 18 comments
Closed

Proposal: Block parameters #589

theunrepentantgeek opened this issue May 16, 2017 · 18 comments

Comments

@theunrepentantgeek
Copy link

Background

Many enhancements made to C# qualify as syntactic sugar, improvements to the syntax of the language that improve clarity and expressiveness. Recent additions include expression oriented member bodies, and var out parameters, while earlier additions include extension methods and the null coalescence (??) operator.

Problem

Framework and library authors often require code to be structured in a particular way. This occurs more frequently in libraries that express strong opinions about how code should be written. Examples include testing frameworks, state machines and workflow engines.

With the syntax available in C#7, it's often possible to declare these structures as wrapper methods that accept lambda expressions for the different pieces of the workflow. However, the syntax for using these involves a non-trivial amount of ceremony and the results are often only readable by those already intimately familiar with the library.

Proposal

Allow the passing of code blocks (delimited by '{' braces '}') for delegate parameters marked with the new context sensitive keyword block.

This syntax would be most useful to the library and framework authors mentioned above, giving them the ability to define constructs that look almost like they're built into the language.

  • Block parameters would appear following the closing parenthesis ()) of the method call, but ahead of the terminating semicolon (;).
  • The block keyword would only be permitted on method parameters with delegate types (Action, Action<T>, Func<R> and so on), and only at the end of the parameter list (thus maintaining parameter order when reading the code).
  • A block parameter may only be followed by other block parameters.
  • Multiple block parameters are identified by a preceding keyword matching the declared parameter name.
  • The code blocks would be semantically identical to lambda expressions (with respect to variable captures, etc).
  • Similar to the way that extension methods with the this keyword can be called with both static and instance syntax, methods using the block keyword would still be callable with C#7 syntax (thus supporting cross-version compatibility).
  • If only block parameters are used by a method, the ( brackets ) of the method call may be omitted (similar to the way object and collection initialization works).

The following is a series of examples to demonstrate how this feature might be used to improve the readability of C# code.

Example: Using IDisposable

In it's simplest form, block actions would easily recreate the functionality of the existing using clause:

// Declaration
void Using<T>(
    T value, 
    block Action body)
    where T: IDisposable
{
    try
    {
         body();
    }
    finally
    {
        value.Dispose();
    }

}

// Use
Using(var connection = new Connection())
{
    // do something
};

// Equivalent code in C# 7
Using(var connection = new Connection(), 
() =>
{
    // do something
});

Note that the closing ; statement terminator is retained to make it clear where the function call finishes. As discussed in #496, skipping the terminating semicolon would limit future options for syntax improvements and introduce ambiguity into the language.

Admittedly, recreating using is pretty trivial - but it gets much better from here.

Example: ISmartDisposable

Sometimes developers want something a bit like IDisposable but with the addition of exception awareness. In a July 2005 blog entry, Oren Eini wrote of his desire for an ISmartDisposable interface, much like IDisposable but with the addition of void Dispose(Exception). With block actions, we can easily implement this:

// Declaration
void SmartUsing<T>(T value, block Action body)
    where T: ISmartDisposable
{
    try
    {
         body();
    }
    catch (Exception ex)
    {
        value.Dispose(ex);
    }
    finally
    {
        value.Dispose();
    }
}

// Use
SmartUsing(var connection = new SmartConnection())
{
    // do something
};

// Equivalent code in C# 7
Using(var connection = new Connection(),
() =>
{
    // do something
});

Example: Telemetry Timing

More usefully, consider writing a static method to capture out of band timing information about an operation as a part of a telemetry package:

// Declaration
static class Telemetry 
{
    static void Time(
        string id,
        string description, 
        block Action<T> body) { ... }
}

// Use
Telemetry.Time("createAccount", $"Creating account {Name}")
{
  // Create the account here 
  // ...
};

// Equivalent code in C# 7
Telemetry.Time("createAccount", $"Creating account {Name}", 
() => {
  // Create the account here 
  // ...
});

Multiple blocks

When multiple arguments are modified with the block keyword, each would be named (following the precedent already set for try..except..finally blocks). The name for each block would be the parameter name specified in the original declaration. Since parameter names are conventionally camel case, these would largely match the style of existing C# keywords, especially where the name is a single word.

A test framework could include direct support for the popular arrange-act-assert style:

// Declaration
void TestCase(
    string description, 
    block Action arrange, 
    block Action act, 
    block Action assert) { ... }

// Use
TestCase("Check that we can do the thing")
arrange 
{
    // Setup
}
act
{
    // Do the thing
}
assert 
{
    // Check the things
};

// Equivalent code in C# 7 - with named parameters
TestCase("Check that we can do the thing",
arrange: () =>
{
    // Setup
},
act: () =>
{
    // Do the thing
},
assert: () => 
{
    // Check the things
});

// Equivalent code in C# 7 - without named parameters
TestCase("Check that we can do the thing",
() =>
{
    // Setup
},
() =>
{
    // Do the thing
},
() => 
{
    // Check the things
});

At this point we can start to see how the proposed syntax is cleaner and easier to read. Observe that the C#7 version without named parameters is much harder to read because the lambda expressions are not named; only developers already familiar with the code would know the purpose of each lambda expression.

Using function blocks

A library that defines retry policies would use a block function to perform the action that might need multiple attempts. If the block succeeds, returning a value, the Retry() method would return that value. If an exception occurs, the function would be retried multiple times until either the retry count was exhausted, or a value returned.

// Declaration
T Retry<T>(block Func<T> action) { ... }

// Use
var result = 
    Retry
    {
        var result = // Get the value
        return result;
    };

// Equivalent code in C# 7
var result = 
    Retry(()=>
    {
        var result = // Get the value
        return result;
    });

Blocks with parameters

Block actions of type Action<T> or Func<T> would allow a parameter to be passed into the appropriate block by reusing the syntax already present for try..catch, starting the block with a declaration of the expected parameters. Contravariance would be in play, allowing the block to be declared with a more generic parameter than expected.

A declarative UX framework could make the validation for a new field in a dynamic data entry form very declarative:

// Declaration
void AddTextEntry(
    string identifier,
    string label,
    block Action<string> validate,
    block Func<bool> enableWhen) { ... }

// Use
AddProperty("knownAs", "Known As")
validate (string value)
{
    // Check value here
}
enable
{
    return UsesAlias == Alias.IsUsed;
};

// Equivalent code in C# 7
AddProperty("knownAs", "Known As",
string value =>
{
    // Check value here
},
() => 
{
    return UsesAlias == Alias.IsUsed;
});

Optional block parameters

Just as regular Action or Func parameters can be made optional by giving them the default value of null, block parameters should behave the same way.

Transactions (whether for working with databases or other transactional systems) could be managed with explicit support for both rollback and compensation, useful for systems that need to undo changes in one system when a transaction elsewhere fails.

// Declaration
void WithTransaction(
    block Action perform, 
    block Action commit,
    block Action rollback = null,
    block Action compensation = null) { ... }

// Use
dbConnection.WithTransaction
perform
{
    // Do the thing
}
commit (Transaction t)
{
    // Final steps
}

// Equivalent code in C# 7
dbConnection.WithTransaction(
perform: () =>
{
    // Do the thing
},
commit: (Transaction t) =>
{
    // Final steps
});

Since the declaration includes only block parameters, the brackets following the method call are elided. This is similar to the way brackets are optional when using object and collection initialization syntax.

@HaloFour
Copy link
Contributor

I don't really see what benefit this provides other than trying to make C# smell more like Swift. To me the proposed code doesn't read any better, and if anything it's harder to discern what is a parameter to the method vs. a keyword in the language.

@jnm2
Copy link
Contributor

jnm2 commented May 16, 2017

@MgSam
Copy link

MgSam commented May 16, 2017

With the syntax available in C#7, it's often possible to declare these structures as wrapper methods that accept lambda expressions for the different pieces of the workflow. However, the syntax for using these involves a non-trivial amount of ceremony and the results are often only readable by those already intimately familiar with the library.

As far as I can tell, the only difference in your examples from lambdas is that these "blocks" don't have () =>. Are those 5 characters really such a big burden?

@theunrepentantgeek
Copy link
Author

theunrepentantgeek commented May 16, 2017

@HaloFour I'm not at all familiar with Swift, so I can't comment. The treatment of parameter names as language keywords is intentional, so I'm not surprised to see that called out.

@MgSam The biggest difference happens when there are multiple block parameters and each block is therefore named. Readers unfamiliar with the library don't then have to guess what each lambda does. See the TestCase example for an illustration.

@HaloFour
Copy link
Contributor

@theunrepentantgeek

Swift has what they call "trailing closures" where a function that takes a lambda as the final parameter may accept that lambda outside of the function call:

reversedNames = names.sorted { $0 > $1 }
// same as
reversedNames = names.sorted(by: { $0 > $1 } )

This proposal seems very similar to that, conceptually.

The biggest difference happens when there are multiple block parameters and each block is therefore named. Readers unfamiliar with the library don't then have to guess what each lambda does.

Why isn't named parameters sufficient here?

@orthoxerox
Copy link

orthoxerox commented May 16, 2017

I think Ruby has them as well. As far as I remember, one important feature is that control flow statements inside a block work with the surrounding method, not with the block itself as a lambda:

int Foo()
{
    int x = MyBlockFunction {
        if (false)
            return 2; //returns from Foo
        else
            goto bar;
    }
    bar: return 3;
}

@MgSam
Copy link

MgSam commented May 16, 2017

@theunrepentantgeek Even in the case of multiple arguments, the only additional difference between named parameters in C# vs your proposed block syntax is a comma and a colon.

I don't see any added expressiveness by your block proposal. In fact, I think your examples of C# 7.0 do a great job of showing why it's not needed.

@svick
Copy link
Contributor

svick commented May 16, 2017

// Equivalent code in C# 7
Using(var connection = new Connection(), 
() =>
{
    // do something
});

This is not actually valid C# 7, you can't declare variables like that. Actual implementation of using using a lambda could look like this:

Using(new Connection(), 
connection =>
{
    // do something
});

@jnm2
Copy link
Contributor

jnm2 commented May 16, 2017

Haha, that's what the Async.Using looks like that I'm using for real over here. 😄

@theunrepentantgeek
Copy link
Author

@HaloFour @MgSam I'll freely admit that named parameters do come close - I've used them in this way - but I don't think they hit the mark, for these reasons:

  • I've seen significant resistance to lambdas that require { braces }; because they're parameter values, some developers believe they should be short. It's the same mindset that favors an intermediate variable over embedding a function call inline (and I don't entirely disagree).

  • Using named parameters relies on the discipline of the developer using them, as in most cases they're optional. Some developers would aggressively remove them (despite their value as documentation) simply because they're optional at that point.

  • There's significant tooling pressure to remove redundant named parameters. For example, Resharper, Refactoring Essentials and Roslynator all have analyzers that highlight redundant names and suggest removing them.

@JohnRusk
Copy link

I like it, and can see a number of places where I would have used it in the past if it had been available.

The comments (above) critiquing the suggestion are only saying what the proposal states quite clearly at the start. Its syntactic sugar. That's not automatically a bad thing. If syntactic sugar was always a bad thing, we'd be writing everything in Lisp.

@svick
Copy link
Contributor

svick commented May 16, 2017

@JohnRusk Syntactic sugar is not a bad thing. Syntactic sugar that just moves some symbols around a bit and doesn't really accomplish anything new is a bad thing.

@theunrepentantgeek
Copy link
Author

@HaloFour Indeed, there are a lot of similarities with trailing closures from Swift. Thanks for the pointer.

One difference (based on a some quick research, please tell me if I'm wrong here): it seems that Swift only allows one such closure - this proposal allows for multiple blocks, using the parameter names as pseudo-keywords.

@svick
Copy link
Contributor

svick commented May 16, 2017

@theunrepentantgeek

I've seen significant resistance to lambdas that require { braces }; because they're parameter values, some developers believe they should be short. It's the same mindset that favors an intermediate variable over embedding a function call inline (and I don't entirely disagree).

How does adding a new syntax for the same thing solve this? If I like short function calls and intermediate variables, I'll probably keep using them, even if this feature was an option.

Using named parameters relies on the discipline of the developer using them, as in most cases they're optional. Some developers would aggressively remove them (despite their value as documentation) simply because they're optional at that point.

You could create an analyzer that enforces named parameters in some cases. If your developers use a style you don't like, you should educate your developers, you don't need to change the language for that.

There's significant tooling pressure to remove redundant named parameters. For example, Resharper, Refactoring Essentials and Roslynator all have analyzers that highlight redundant names and suggest removing them.

You can usually configure those tools to disable some of their analyzers. Again, you don't need to change the language just so that ReSharper stops bothering you.

@theunrepentantgeek
Copy link
Author

@svick Really appreciate your comments.

How does adding a new syntax for the same thing solve this?

Specifically, because the block syntax directly supports multiple lines of code in a way that fits in with the rest of the language.

I believe that a developer who dislikes parameter calls that run over many lines might object to this:

retryPolicy.Perform(()=>
{
    using (var connection = CreateConnection())
    {
        var q = ...
        return connection.RunQuery(q);
    }
});

while being quite comfortable with this:

retryPolicy.Perform
{
    using (var connection = CreateConnection())
    {
        var q = ...
        return connection.RunQuery(q);
    }
};

Exactly because the later syntax looks much more like something built into the language.

@theunrepentantgeek
Copy link
Author

@orthoxerox It's interesting that in Ruby the control flow statements apply to the outer scope; I'd specifically intended this proposal to be purely some syntactic sugar for lambda expressions, which would scope things like break and return within the block parameter.

@theunrepentantgeek
Copy link
Author

theunrepentantgeek commented Jun 30, 2017

A friend just forwarded me an interesting link that's related to this proposal:

Fantastic DSLs and where to find them
(http://serce.me/posts/29-06-2017-fantastic-dsls/)

The article is a discussion on creating DSLs in Kotlin that references a very similar feature to this proposal. It seems the Kotlin feature is pretty similar to the Swift one already noted by @HaloFour on May 17th; both of them allow for a trailing lambda to be expressed outside of the normal parameter list.

The "Droid" and "HTML" DSLs from that article would be possible in C# with block parameters. It's possible the other DSLs discussed would be as well - but my Kotlin isn't up to the task of judging.

@theunrepentantgeek
Copy link
Author

Closing this to reduce clutter in the repo.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

8 participants