Many popular languages support the use of local functions and in C# 7, support for them was announced with relatively little fanfare. As someone that would consider themselves a C# power-user, I seldom took advantage of the feature until I realized just how much it could help with making code more readable, specifically in the context as a replacement for comments/hacks, unit tests, and in general just to clean things up.
What are local functions exactly?
Local functions are private methods of a type that are nested in another member. They can only be called from their containing member. Local functions can be declared in and called from:
- Methods, especially iterator methods and async methods
- Constructors
- Property accessors
- Event accessors
- Anonymous methods
- Lambda expressions
- Finalizers
As with most things, sometimes, it's easier to just show you what a local function looks like:
public static IEnumerable<Address> SanitizeAddresses(List<Address> addresses)
{
foreach(Address address in addresses)
{
yield return Anonymize(address);
}
// This is a local function
Address Anonymize(Address address) { ... }
}
Cleaning Up Comments with Local Functions
One of the first use cases that comes to mind that functions can help alleviate is any pesky sanitation or business logic rules, particularly those around string manipulation, etc. If you've worked in enough business applications, you've undoubtably seen something terrible with some massive comment as to why it's being done:
public static User ProcessUser(User user)
{
// All names must conform to some crazy Dr. Seuss-eqsue rhyming scheme
// along with every other character being placed by it's closest numerically
// shaped equivalent
var seussifyExpression = new Regex("...");
user.Name = seussifyExpression.Replace(user.Name, "...");
user.Name = user.Name
.Replace(..., ...)
.Replace(..., ...)
.Replace(..., ...);
// Other processes omitted for brevity
return user;
}
As you can see here, we have a series of chained replacements, some relying on strings, and others relying on regular expressions, which can make a method pretty clunky, especially if there's multiple operations to perform. Now, this is where you can define a local function to encapsulate all this business logic to replace your crazy comment:
public static User ProcessUser(User user)
{
SanitizeName(user)
// Other processes omitted for brevity
return user;
void SanitizeName(User user)
{
var seussifyExpression = new Regex("...");
user.Name = seussifyExpression.Replace(user.Name, "...");
user.Name = user.Name
.Replace(..., ...)
.Replace(..., ...)
.Replace(..., ...);
return user;
}
}
You could easily name your local function whatever you like, even ApplyBusinessLogicNamingRules()
and include any necessary comments for reasoning that you'd like within there (if you absolutely need to answer why you are doing something), but this should help the rest of your code tell you what it's doing without a comment explicitly writing it all out.
Going All Reading Rainbow with Local Functions
If readability isn't the single most important thing about code, then it's damn close to the top.
LINQ is another popular area that local functions can assist with, especially if you have to do any type of crazy filtering logic over a series of records. You can define a series of local functions that can cover each step of your filtering process (or any process really), and more easily reason about your code from a readability perspective:
public List<int> FindPrimesStartingWithASpecificLetter(List<int> numbers, int startingDigit)
{
return numbers.Where(n => n > 1 && Enumerable.Range(1, n).Where(x => n % x == 0).SequenceEqual(new [] {1, n }))
.Where(n => $"{n}".StartsWith($"{startingDigit}"));
}
While succinct, it doesn't exactly read well. Let's take a gander at what it looks like after rubbing some local functions on it:
public List<int> FindPrimesStartingWithASpecificLetter(List<int> numbers, int startingDigit)
{
return numbers.Where(x => IsPrime(x) && StartsWithDigit(x, startingDigit));
bool IsPrime(int n) => return n > 1 && Enumerable.Range(1, n).Where(x -> n % n == 0).SequenceEqual(new [] { 1, n }));
bool StartsWithDigit(int n, int startingDigit) => return $"{n}".StartsWith($"{startingDigit}");
}
As you can see, local functions are assisting with wrapping up all the ugly/nasty logic within their own tiny little functions. This is a really trivial case, but as you might imagine if you have lines-upon-lines of code that isn't touching anything outside of one method, it's likely a solid candidate for a local function.
Testing, Testing, Lo-ca-lly!
If you've spent any amount of time writing tests, either unit or integration, you are probably familiar with the fabled Arrange-Act-Assert pattern, which is used to separate each piece of functionality when testing a given piece of code as follows:
- Arrange all necessary preconditions and inputs.
- Act on the object or method under test.
- Assert that the expected results have occurred.
As you might imagine, this could lend itself to the pattern quite well for complex test cases:
public void IsThisAnArrangeActAssertLocalFunction()
{
Arrange();
Act();
Assert();
Arrange() { ... }
Act() { ... }
Assert() { ... }
}
Is it practical? Does it fit all use cases? Is it something that you'd ever find yourself using? The answers to all of these might be an overwhelming no, but it does seem like a scenario where local functions could play a role.
Choose Your Own Adventure
Local functions present a few interesting options that fit some scenarios better than others. As a replacement for large comments or very messy business logic - absolutely. In unit tests or little one liners - probably not. With most new features, especially those that are sugary, it's really up to you and your team to see if they work for you. While they may seem appealing in some situations, they also seem ripe for abuse, potentially cluttered methods, and other issues that would completely defeat the purpose of using them in the first place.
So, if you choose to go down this road of local functions, proceed with care.
Top comments (0)