Local functions arrived in C# 7.0. They are scoped to the containing method and cannot be called from outside it. Unlike lambdas stored in delegates, local functions can be recursive, support all parameter and return type features, and integrate seamlessly with async/await and iterators. They close over local variables, similar to closures, but with the structure of traditional methods.
Why it matters
Local functions keep helper methods visible only where they’re needed. Instead of creating a separate private method (polluting the class interface) or a lambda (less readable for complex logic), local functions strike a balance: they’re structured, recursive-capable, and scoped. This is especially valuable for validation logic, recursive algorithms, or multi-step procedures that belong to one method.
Practical examples
Validation within a method:
public void ProcessOrder(Order order)
{
ValidateOrder(order);
ShipOrder(order);
void ValidateOrder(Order o)
{
if (o.Items.Count == 0) throw new ArgumentException("Order empty");
if (o.Total < 0) throw new ArgumentException("Negative total");
}
}
Recursive tree traversal:
public int CalculateDepth(TreeNode root)
{
return CalculateDepthLocal(root);
int CalculateDepthLocal(TreeNode node)
{
if (node == null) return 0;
return 1 + Math.Max(
CalculateDepthLocal(node.Left),
CalculateDepthLocal(node.Right)
);
}
}
Capturing outer scope:
public decimal ApplyDiscount(decimal price, int customerTier)
{
decimal discountRate = customerTier switch { 1 => 0.05m, 2 => 0.10m, _ => 0 };
return CalculateFinalPrice(price);
decimal CalculateFinalPrice(decimal p) => p * (1 - discountRate);
}
Cautions
Local functions are visible and callable only within their containing method; they cannot be passed as delegate parameters without wrapping in a lambda. They do not appear in reflection or IntelliSense outside their scope, which is intentional encapsulation but can surprise developers unfamiliar with the feature. Performance is equivalent to private methods; the compiler generates hidden private methods at the class level.
Basic local functions
Define helper functions inside methods to encapsulate logic and reduce pollution of class scope.
Valid since C# 7.0
// Before: Separate private methods (clutters class interface)
public class OrderProcessorOld
{
public void ProcessOrder(Order order)
{
ValidateOrderInternal(order);
CalculateTotal(order);
ShipOrder(order);
}
private void ValidateOrderInternal(Order order)
{
if (order == null) throw new ArgumentNullException(nameof(order));
if (order.Items.Count == 0) throw new ArgumentException("Order is empty");
if (order.Items.Any(item => item.Quantity <= 0))
throw new ArgumentException("Invalid quantity");
}
private void CalculateTotal(Order order)
{
order.Total = order.Items.Sum(item => item.Price * item.Quantity);
}
}
// After: Local functions (encapsulated at point of use)
public class OrderProcessorNew
{
public void ProcessOrder(Order order)
{
ValidateOrder(order);
CalculateTotal(order);
ShipOrder(order);
// Local validation function - visible only to ProcessOrder
void ValidateOrder(Order o)
{
if (o == null) throw new ArgumentNullException(nameof(o));
if (o.Items.Count == 0) throw new ArgumentException("Order is empty");
if (o.Items.Any(item => item.Quantity <= 0))
throw new ArgumentException("Invalid quantity");
}
// Local calculation function
void CalculateTotal(Order o)
{
o.Total = o.Items.Sum(item => item.Price * item.Quantity);
}
}
private void ShipOrder(Order order) { /* ... */ }
}
public class Order
{
public List<OrderItem> Items { get; set; } = new();
public decimal Total { get; set; }
}
public class OrderItem
{
public decimal Price { get; set; }
public int Quantity { get; set; }
}
Local functions with recursion and closures
Use local functions for recursive algorithms and to capture variables from the enclosing scope.
Valid since C# 7.0
public class LocalFunctionsAdvanced
{
// Recursive local function
public long Factorial(int n)
{
if (n < 0) throw new ArgumentException("n must be non-negative");
return FactorialLocal(n);
long FactorialLocal(int x) => x <= 1 ? 1 : x * FactorialLocal(x - 1);
}
// Fibonacci with memoization using local function
public int Fibonacci(int n)
{
var cache = new Dictionary<int, int>();
return FibLocal(n);
int FibLocal(int x)
{
if (x <= 1) return x;
if (cache.TryGetValue(x, out int cached)) return cached;
int result = FibLocal(x - 1) + FibLocal(x - 2);
cache[x] = result;
return result;
}
}
// Local functions capturing outer variables (closure)
public Func<int, int> CreateMultiplier(int factor)
{
// Local function captures 'factor' from enclosing scope
int Multiply(int x) => x * factor;
// Return as delegate
return Multiply;
}
// Multiple local functions with interdependencies
public void GenerateReport(List<int> data)
{
var sum = 0;
var max = int.MinValue;
ProcessData();
PrintSummary();
void ProcessData()
{
foreach (var value in data)
{
sum += value;
if (value > max) max = value;
}
}
void PrintSummary()
{
double average = data.Count > 0 ? (double)sum / data.Count : 0;
Console.WriteLine($"Sum: {sum}, Average: {average:F2}, Max: {max}");
}
}
// TreeNode for recursive example
public class TreeNode
{
public int Value { get; set; }
public TreeNode Left { get; set; }
public TreeNode Right { get; set; }
}
// Binary tree traversal using local recursion
public List<int> InOrderTraversal(TreeNode root)
{
var result = new List<int>();
Traverse(root);
return result;
void Traverse(TreeNode node)
{
if (node == null) return;
Traverse(node.Left);
result.Add(node.Value);
Traverse(node.Right);
}
}
}
Local async functions and iterators
Combine local functions with async/await and yield to create powerful iterative and asynchronous patterns.
Valid since C# 7.0
using System;
using System.Collections.Generic;
public class LocalFunctionsAsyncAndIterators
{
// Local async function
public async System.Threading.Tasks.Task<string> FetchDataAsync(string url)
{
var client = new System.Net.Http.HttpClient();
var data = await FetchInternal();
return data;
async System.Threading.Tasks.Task<string> FetchInternal()
{
var response = await client.GetAsync(url);
return await response.Content.ReadAsStringAsync();
}
}
// Local iterator function (generator)
public IEnumerable<int> GenerateSequence(int count)
{
return GetNumbers();
IEnumerable<int> GetNumbers()
{
for (int i = 0; i < count; i++)
{
yield return i * 2;
}
}
}
// Local async iterator (C# 8.0+)
public async System.Collections.Generic.IAsyncEnumerable<int> FetchNumbersAsync(int count)
{
await foreach (var num in GetNumbersAsync())
{
yield return num;
}
async IAsyncEnumerable<int> GetNumbersAsync()
{
for (int i = 0; i < count; i++)
{
await System.Threading.Tasks.Task.Delay(100); // Simulate async work
yield return i;
}
}
}
// Local function with lazy evaluation
public IEnumerable<T> FilterAndTransform<T>(IEnumerable<T> source, Func<T, bool> predicate, Func<T, T> transform)
{
return ExecuteTransform();
IEnumerable<T> ExecuteTransform()
{
foreach (var item in source)
{
if (predicate(item))
{
yield return transform(item);
}
}
}
}
// Combining multiple local functions with async
public async System.Threading.Tasks.Task<(int success, int failed)> ProcessItemsAsync(string[] items)
{
int successCount = 0;
int failCount = 0;
foreach (var item in items)
{
if (await TryProcess(item))
successCount++;
else
failCount++;
}
return (successCount, failCount);
async System.Threading.Tasks.Task<bool> TryProcess(string item)
{
try
{
await ProcessItemAsync(item);
return true;
}
catch
{
return false;
}
}
async System.Threading.Tasks.Task ProcessItemAsync(string item)
{
await System.Threading.Tasks.Task.Delay(50); // Simulate work
Console.WriteLine($"Processed: {item}");
}
}
// Local async function with resource management
public async System.Threading.Tasks.Task<int> CountLinesAsync(string filePath)
{
return await CountLines();
async System.Threading.Tasks.Task<int> CountLines()
{
int count = 0;
using (var reader = new System.IO.StreamReader(filePath))
{
string line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (!string.IsNullOrWhiteSpace(line))
count++;
}
}
return count;
}
}
}