Back to Blog
C#

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.

C#Local FunctionsLambda ExpressionsPerformanceClosures
Illustration of a C# local function nested inside a method, showing scope and encapsulation.

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:

FeatureLocal FunctionLambda Expression
SyntaxNamed method declarationAnonymous expression or statement
NamingHas an explicit nameNo name, assigned to a delegate or expression tree
RecursionCan call itself directly by nameRequires assigning to a delegate variable first
AttributesCan have attributes (e.g., [Conditional])Cannot have attributes
AllocationOften no delegate allocation if not capturedTypically 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 await logic. A local function can be async and is declared with the same async modifier.
  • 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 an IEnumerable<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 ref and out parameters, but using them with yield is not allowed. The compiler will reject a yield inside a method that has ref or out parameters.
  • 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.

c# local function: Practical Usage and Code Examples | RYUSLOG DEV