Let’s discuss another of the features that may be coming to the next version of the C# language: Local Functions.
This post discusses a proposed feature. This feature may or may not be released. If it is released, it may or may not be part of the next version of C#. You can contribute to the ongoing discussions here.
Local functions would enhance the language by enabling you to define a function inside the scope of another function. That supports scenarios where, today, you define a private
method that is called from only one location in your code. A couple scenarios show the motivation for the feature.
Suppose I created an iterator method that was a more extended version of Zip()
. This version puts together items from three different source sequences. A first implementation might look like this:
public static IEnumerable<TResult>
SuperZip<T1, T2, T3, TResult>(IEnumerable<T1> first,
IEnumerable<T2> second,
IEnumerable<T3> third,
Func<T1, T2, T3, TResult> Zipper)
{
var e1 = first.GetEnumerator();
var e2 = second.GetEnumerator();
var e3 = third.GetEnumerator();
while (e1.MoveNext() && e2.MoveNext() && e3.MoveNext())
yield return Zipper(e1.Current, e2.Current, e3.Current);
}
This method would throw a NullReferenceException
in the case where any of the source collections was null
, or if the Zipper
function was null
. However, because this is an iterator method (using yield return), that exception would not be thrown until the caller begins to enumerate the result sequence.
That can make it hard to work with this method: errors may be observed in code locations that are not near the code that introduced the error. As a result, many libraries split this into two methods. The public
method validates arguments. A private
method implements the iterator logic:
public static IEnumerable<TResult>
SuperZip<T1, T2, T3, TResult>(IEnumerable<T1> first,
IEnumerable<T2> second,
IEnumerable<T3> third,
Func<T1, T2, T3, TResult> Zipper)
{
if (first == null)
throw new NullReferenceException("first sequence cannot be null");
if (second == null)
throw new NullReferenceException("second sequence cannot be null");
if (third == null)
throw new NullReferenceException("third sequence cannot be null");
if (Zipper == null)
throw new NullReferenceException("Zipper function cannot be null");
return SuperZipImpl(first, second, third, Zipper);
}
private static IEnumerable<TResult>
SuperZipImpl<T1, T2, T3, TResult>(IEnumerable<T1> first,
IEnumerable<T2> second,
IEnumerable<T3> third,
Func<T1, T2, T3, TResult> Zipper)
{
var e1 = first.GetEnumerator();
var e2 = second.GetEnumerator();
var e3 = third.GetEnumerator();
while (e1.MoveNext() && e2.MoveNext() && e3.MoveNext())
yield return Zipper(e1.Current, e2.Current, e3.Current);
}
This solves the problem. The arguments are evaluated, and if any are null
, an exception is thrown immediately. But it isn’t as elegant as we might like. The SuperZipImpl
method is only called from the SuperZip()
method. Months later, it may be more difficult to understand what was originally written, and that the SuperZipImpl
is only referred to from this one location.
Local functions make this code more readable. Here would be the equivalent code using a Local Function implementation:
public static IEnumerable<TResult>
SuperZip<T1, T2, T3, TResult>(IEnumerable<T1> first,
IEnumerable<T2> second,
IEnumerable<T3> third,
Func<T1, T2, T3, TResult> Zipper)
{
if (first == null)
throw new NullReferenceException("first sequence cannot be null");
if (second == null)
throw new NullReferenceException("second sequence cannot be null");
if (third == null)
throw new NullReferenceException("third sequence cannot be null");
if (Zipper == null)
throw new NullReferenceException("Zipper function cannot be null");
IEnumerable<TResult>Iterator()
{
var e1 = first.GetEnumerator();
var e2 = second.GetEnumerator();
var e3 = third.GetEnumerator();
while (e1.MoveNext() && e2.MoveNext() && e3.MoveNext())
yield return Zipper(e1.Current, e2.Current, e3.Current);
}
return Iterator();
}
Notice that the local function does not need to declare any arguments. All the arguments and local variables in the outer function are in scope. This minimizes the number of arguments that need to be declared for the inner function. It also minimizes errors. The local Iterator()
method can be called only from inside SuperZip()
. It is very easy to see that all the arguments have been validated before calling Iterator()
. In larger classes, it could be more work to guarantee that if the iterator method was a private
method in a large class.
This same idiom would be used for validating arguments in async
methods.
This example method shows the pattern:
public static async Task<int> PerformWorkAsync(int value)
{
if (value < 0)
throw new ArgumentOutOfRangeException("value must be non-negative");
if (value > 100)
throw new ArgumentOutOfRangeException("You don't want to delay that long!");
await Task.Delay(value * 500);
return value * 500;
}
This exhibits the same issue as the iterator method. This method doesn’t synchronously throw exceptions, because it is marked with the ‘async
’ modifier. Instead, it will return a faulted task. That Task
object contains the exception that caused the fault. Calling code will not observe the exception until the Task
returned from this method is awaited (or its result is examined).
In the current version of C#, that leads to this idiom:
public static Task<int> PerformWorkAsync2(int value)
{
if (value < 0)
throw new ArgumentOutOfRangeException("value must be non-negative");
if (value > 100)
throw new ArgumentOutOfRangeException("You don't want to delay that long!");
return PerformWorkImpl(value);
}
private static async Task<int> PerformWorkImpl(int value)
{
await Task.Delay(value * 500);
return value * 500;
}
Now, the programming errors cause a synchronous exception to be thrown (from PerformWorkAsync
) before calling the async
method that leverages the async
and await
features. This idiom is also easier to express using local functions:
public static Task<int> PerformWorkAsync(int value)
{
if (value < 0)
throw new ArgumentOutOfRangeException("value must be non-negative");
if (value > 100)
throw new ArgumentOutOfRangeException("You don't want to delay that long!");
async Task<int> AsyncPart()
{
await Task.Delay(value * 500);
return value * 500;
}
return AsyncPart();
}
The overall effect is a more clear expression of your design. It’s easier to see that a local function is scoped to its containing function. It’s easier to see that the local function and its containing method are closely related.
This is just a small way where C# 7 can make it easier to write code that more clearly expresses your design.