C# Local Function: Syntax, Use Cases, and Performance
c# local function: Learn how to declare and use C# local functions, when they beat lambda expressions, and how they affect performance and code clarity.
In C# 7.0, the language introduced local functions: methods declared inside the body of another method. They give you a way to define helper logic exactly where it is used, without adding private methods to the class. A c# local function can capture variables from the enclosing method, call itself recursively, and even be declared after the code that calls it. This article covers the syntax, the differences from lambda expressions, and the performance implications you should consider before using them.
Declaring a Local Function
A local function is declared using the same syntax as a regular method, but it appears inside another method body. Here is a minimal example:
public void ProcessOrder(Order order) { decimal CalculateTotal(Order o) { return o.Items.Sum(item => item.Price * item.Quantity); } decimal total = CalculateTotal(order); // Continue processing with total }
The local function CalculateTotal is only visible within ProcessOrder. It can access parameters and local variables of the enclosing method. One useful detail is that a local function can be called before its declaration because the compiler hoists it to the method level. This allows you to place the call first and the definition later, which can improve readability when the call is the primary flow.
Local Functions vs. Lambda Expressions
Local functions and lambda expressions both let you define inline logic, but they differ in several important ways. The table below summarizes the key distinctions:
| Feature | Local Function | Lambda Expression |
|---|---|---|
| Syntax | Named method declaration | Anonymous expression or statement |
| Naming | Has an explicit name | No name, assigned to a delegate or expression tree |
| Recursion | Can call itself directly by name | Requires assigning to a delegate variable first |
| Attributes | Can have attributes (e.g., [Conditional]) | Cannot have attributes |
| Allocation | Often no delegate allocation if not captured | Typically allocates a delegate instance |
Because a local function is a named method, it can be used for recursion without the awkward pattern of declaring a delegate and then assigning it. For example:
int Factorial(int n) { return n <= 1 ? 1 : n * Factorial(n - 1); }
With a lambda, you would need to write:
Func<int, int> factorial = null; factorial = n => n <= 1 ? 1 : n * factorial(n - 1);
The local function version is clearer and avoids the null initialization.
Capturing Variables and Closure Behavior
Like lambdas, local functions can capture variables from the enclosing scope. When a local function captures a variable, the compiler creates a closure to hold that variable. The exact allocation behavior depends on how the variable is used and the compiler version. In many cases, the compiler can represent the closure as a struct, avoiding heap allocation. However, if the local function is converted to a delegate, the closure is typically boxed to a reference type.
A local function that does not capture any variables is compiled as a static method, which means it can be called without any closure allocation. This is a significant advantage over lambdas when the logic is pure and only uses its parameters.
Performance Characteristics
The JIT compiler can inline a local function because its body is known at the call site. Inlining eliminates the method call overhead and can enable further optimizations. A lambda that is converted to a delegate, on the other hand, typically requires a delegate instance. Even if the delegate is cached, the call goes through the delegate's Invoke method, which may prevent inlining. The difference is most noticeable in hot paths where a helper is called repeatedly inside a loop.
Consider this example:
public void ProcessItems(List<Item> items) { bool IsValid(Item item) { return item.Quantity > 0 && item.Price >= 0; } foreach (var item in items) { if (IsValid(item)) { // Process item } } }
Here, IsValid does not capture any variables, so the compiler treats it as a static method. The JIT can inline the call, avoiding any per-iteration overhead. If you used a lambda with Func<Item, bool>, you would need to allocate a delegate for each call unless the compiler caches it, which is not guaranteed in all contexts.
Practical Use Cases
Local functions shine in a few specific scenarios:
- Validation logic that is only needed in one method. Placing it as a local function keeps the method self-contained and avoids polluting the class with a private method that is never used elsewhere.
- Iterator methods that use
yield. A common pattern is to validate arguments eagerly and then delegate to a local iterator function. For example:
public IEnumerable<int> GetNumbers(int start, int end) { if (start > end) throw new ArgumentException("Start must be less than or equal to end."); return GetNumbersCore(); IEnumerable<int> GetNumbersCore() { for (int i = start; i <= end; i++) yield return i; } }
- Async methods where you need a small helper to avoid duplicating
awaitlogic. A local function can beasyncand is declared with the sameasyncmodifier. - Recursive algorithms where the recursion is only relevant inside a single method. The named function makes the recursion explicit and avoids the delegate assignment dance.
Common Pitfalls and Limitations
Local functions have a few limitations to keep in mind:
- They are not accessible outside the enclosing method. If you need the same helper in multiple methods, a private method or a static local function (if you move it to a static class) is a better choice.
- A local function that is an iterator (contains
yield) cannot be called directly; it returns anIEnumerable<T>that must be enumerated. This is fine, but it means the local function itself is not executed until the enumeration starts. - Local functions can have
refandoutparameters, but using them withyieldis not allowed. The compiler will reject ayieldinside a method that hasreforoutparameters. - If you need to pass a local function as a delegate to another method, you must convert it explicitly. This conversion allocates a delegate, which may negate some performance benefits. In that case, a lambda might be equally efficient.
Choosing Between Local Functions and Other Approaches
Use a local function when the helper is specific to one method and you want the benefits of a named method: recursion, attributes, or the potential for inlining without delegate allocation. Use a lambda when you need to pass the logic as a delegate to another API, such as LINQ methods or event handlers. For a one-off helper that does not need to be reused, a local function often leads to cleaner code than a lambda because it has a descriptive name and can be placed after the call site.
If the helper is used in several methods, promote it to a private method or a static method. If it is only used in one method but is long and complex, a local function can still be appropriate, but you should consider whether extracting it to a separate method improves testability. Local functions are not directly unit-testable, so if the logic is non-trivial and worth testing independently, a private method is a better choice.