Functions
These methods take a delegate and return a new delegate with extra behavior: a cache, a shared in-flight call, a
quiet period, a run-once guard. Reach for them when you would otherwise write a ConcurrentDictionary, a Timer
or a CancellationTokenSource by hand around a function. Pipe and Tap are the exception: they work on any
value and let you write a chain of calls left to right.
At a glance
| Method | What it does |
|---|---|
Memoize | Caches a pure function's result per argument (one or two arguments). Thread-safe. |
MemoizeAsync | Caches an async function per argument, forever or for a time-to-live. Shares in-flight calls. |
SingleFlight | Makes overlapping async calls share one run. Caches nothing after it completes. |
Latest | Each new call cancels the previous call that is still running. Made for search-as-you-type. |
Debounce | Runs an action once, after a burst of calls has stopped. |
Throttle | Runs an action at most once per interval, without losing the last call. |
Once | Runs an action or function only once. The function form caches its result. |
Negate | Returns the opposite predicate. |
Compose | Chains two functions: first, then second. |
Pipe | Passes a value to a function, so calls read left to right. |
Tap | Runs a side effect on a value and returns the value unchanged. |
All of these are in the System namespace, in the class FunctionExtensions, so they need no extra using.
Why a delegate variable is needed
C# cannot call a method on a lambda or on a method name (a "method group"). Neither has a type until the compiler
converts it to a delegate, so there is nothing to extend. Assign it to a Func<...> or Action variable first,
then call the extension on the variable.
// Does not compile: a method name is not a value you can call methods on.
// Func<string, int> cached = CountWords.Memoize();
// Does not compile: neither is a lambda.
// Func<string, int> cached = (text => text.Length).Memoize();
Func<string, int> countWords = CountWords;
Func<string, int> cachedCountWords = countWords.Memoize(); // compiles
// A cast works too, but the variable reads better.
Func<string, int> alsoCached = ((Func<string, int>)CountWords).Memoize();
static int CountWords(string text) => text.Split(' ').Length;
The cache, timer or in-flight state lives inside the delegate that the method returns. Store it in a field and reuse
it. Calling countWords.Memoize()(text) on every request creates a new, empty cache each time and caches nothing.
Pipe and Tap do not need this, because their receiver is a value and the lambda is only an argument.
Caching results
Memoize
Memoize wraps a function so it computes each argument once and then answers from a cache. Use it for pure
functions that are expensive to call: same input, same output, no side effects. There are two overloads, for
Func<T, TResult> and Func<T1, T2, TResult>.
int calls = 0;
Func<string, int> countLines = text =>
{
calls++;
return text.Split('\n').Length;
};
Func<string, int> cachedCountLines = countLines.Memoize();
int first = cachedCountLines("line 1\nline 2"); // → 2
int second = cachedCountLines("line 1\nline 2"); // → 2 (from the cache)
int third = cachedCountLines("single line"); // → 1
// calls → 2
The two-argument form caches per pair, so argument order matters: (1, 2) and (2, 1) are two entries.
int calls = 0;
Func<string, int, decimal> shippingCost = (zone, weightKg) =>
{
calls++;
return zone == "EU" ? 4.50m + (weightKg * 1.20m) : 9.00m + (weightKg * 2.00m);
};
Func<string, int, decimal> cachedShippingCost = shippingCost.Memoize();
decimal euParcel = cachedShippingCost("EU", 2); // → 6.90m
decimal sameParcel = cachedShippingCost("EU", 2); // → 6.90m (from the cache)
decimal usParcel = cachedShippingCost("US", 2); // → 13.00m
// calls → 2
- Thread-safe. Concurrent first calls for the same argument share one computation. Different arguments compute in parallel.
- Failures are not cached. If the function throws, the exception reaches the caller and the next call for that argument tries again.
nullis a valid argument.cached(null)is computed once and cached like any other value, in both overloads.- Keys use default equality. For a class without
Equals/GetHashCode, two equal-looking objects are two different keys. Use strings, numbers, enums or records as arguments. MemoizethrowsArgumentNullException(parameterfunction) when the delegate isnull.
Every distinct argument stays in memory for as long as the returned delegate lives. Memoize functions with a small,
bounded set of inputs (country codes, enum values, a fixed set of templates). For unbounded inputs, use
MemoizeAsync with a time-to-live, or a real cache.
MemoizeAsync
MemoizeAsync caches an async function (Func<T, Task<TResult>>) per argument. It is a small in-process cache for
configuration, lookups and slow API calls. Callers that ask for the same argument while the call is still running get
the same call; they do not start a second one.
There are two overloads:
| Overload | Cache lifetime |
|---|---|
MemoizeAsync() | Forever (as long as the returned delegate lives). |
MemoizeAsync(TimeSpan timeToLive) | timeToLive after each call completes successfully. |
int calls = 0;
Func<string, Task<decimal>> fetchRate = async currency =>
{
Interlocked.Increment(ref calls);
await Task.Delay(200); // a slow HTTP call in real code
return currency == "EUR" ? 0.92m : 1.00m;
};
Func<string, Task<decimal>> cachedRate = fetchRate.MemoizeAsync(5.Minutes());
decimal[] rates = await Task.WhenAll(cachedRate("EUR"), cachedRate("EUR"), cachedRate("EUR"));
// rates → [0.92m, 0.92m, 0.92m]
// calls → 1 (the three callers shared one in-flight call)
decimal again = await cachedRate("EUR"); // → 0.92m (from the cache, no call)
// calls → 1
5.Minutes() comes from the duration helpers; TimeSpan.FromMinutes(5) works the same.
How the cache behaves:
- Failures are not cached. A faulted task, or a function that throws before it returns a task, is removed. The caller sees the exception and the next call for that argument tries again.
- The time-to-live starts when the call completes, not when it starts. A call that runs longer than the time-to-live is still shared by everyone who asks for it while it runs.
- Expired entries are removed when their argument is requested again. A sweep also runs during calls (checked every 64 calls, at most once per time-to-live), so arguments that are never requested again do not stay in memory. No background timer runs.
- No size limit. Every distinct argument requested within one time-to-live is held until it expires.
- Expiry uses a monotonic clock, so changes to the system clock do not affect it.
A call that never completes is never expired or swept: every later caller for that argument waits on it for as long as the delegate lives. Add a timeout inside the function so a hung call fails, is evicted, and is retried.
Func<int, Task<string>> loadCustomerName = id => customerApi.GetNameAsync(id);
Func<int, Task<string>> guarded = id => loadCustomerName(id).WithTimeout(10.Seconds());
Func<int, Task<string>> cachedCustomerName = guarded.MemoizeAsync(5.Minutes());
// A call that hangs now throws TimeoutException after 10 seconds.
// The next call for the same id calls the API again; a successful result is then cached.
WithTimeout is described on the Tasks page.
Arguments and exceptions:
nullis a valid argument and is cached like any other value.timeToLivemust be positive. Zero or a negative value throwsArgumentOutOfRangeException(parametertimeToLive).Timeout.InfiniteTimeSpanmeans "never expire". A value too large to add to the clock, such asTimeSpan.MaxValue, also behaves as infinite.- A
nullfunction throwsArgumentNullException(parameterfunction).
Sharing and cancelling async calls
SingleFlight
SingleFlight makes overlapping calls share one run. While a call is in flight, every other call returns the
same task. Once that task completes, with a result or an exception, the next call starts a fresh run. Nothing is
kept after completion. This is Go's singleflight: the fix for a "thundering herd" where a hundred requests arrive
during one slow load and trigger a hundred loads.
There are two overloads: Func<Task<T>> (one shared run) and Func<TKey, Task<T>> (one shared run per key).
int loads = 0;
Func<Task<int>> countPendingOrders = async () =>
{
Interlocked.Increment(ref loads);
await Task.Delay(200); // a slow query in real code
return 42;
};
Func<Task<int>> sharedCount = countPendingOrders.SingleFlight();
int[] counts = await Task.WhenAll(Enumerable.Range(0, 100).Select(_ => sharedCount()));
// counts → [42, 42, ..., 42] (100 items)
// loads → 1
int later = await sharedCount(); // → 42
// loads → 2 (the first run had finished, so this call started a new one)
The keyed form de-duplicates per key. Calls for different keys run independently.
Func<int, Task<string>> fetchUserName = id => userApi.GetNameAsync(id);
Func<int, Task<string>> sharedFetch = fetchUserName.SingleFlight();
Task<string> first = sharedFetch(42);
Task<string> second = sharedFetch(42); // the same task as `first` while it runs
Task<string> other = sharedFetch(7); // a separate run for a different key
bool shared = ReferenceEquals(first, second); // → true
- Errors are shared, not kept. If the run fails, every caller that joined it sees the same exception. The next call starts a new run. A function that throws synchronously produces a faulted task; the call itself does not throw.
- The keyed form does not accept
nullkeys.TKeyhas anotnullconstraint. - There is no cancellation token: one caller cannot cancel the shared run for the others. Put a timeout inside the operation if it can hang.
- A
nulloperation throwsArgumentNullException(parameteroperation).
SingleFlight or MemoizeAsync?
Both share an in-flight call. They differ in what happens after it completes.
SingleFlight | MemoizeAsync | |
|---|---|---|
| Concurrent calls for one argument | Share one run | Share one run |
| After the run completes | Nothing is kept. The next call runs again. | The result is cached (forever or for the time-to-live). |
| Failed run | All joined callers see the error; the next call runs again | All joined callers see the error; the next call runs again |
null argument | Not allowed (keyed form) | Allowed |
| Memory | Only calls that are running | Every argument, until it expires |
| Use for | Data that must be fresh on each call, but should not be loaded twice at once | Data that may be reused for a while |
A common pattern is to use SingleFlight around the "reload" step of your own cache. When an entry expires, the
requests that miss at the same time then trigger one reload instead of one per request.
Latest
Latest gives "only the latest call matters" semantics. Every call cancels the token handed to the previous call
that is still running, so a slow, stale response can never overtake a newer one. It is made for search-as-you-type
and for "refresh" buttons.
The operation receives a token that is linked with the caller's own token, so it is cancelled when either the caller
cancels or a newer call starts. There are two overloads: Func<TArg, CancellationToken, Task<T>> and
Func<CancellationToken, Task<T>>.
Func<string, CancellationToken, Task<string[]>> search =
(query, token) => productApi.SearchAsync(query, token);
Func<string, CancellationToken, Task<string[]>> searchLatest = search.Latest();
Task<string[]> first = searchLatest("lap", CancellationToken.None);
Task<string[]> second = searchLatest("laptop", CancellationToken.None);
// first → throws OperationCanceledException (its token was cancelled by the second call)
// second → the results for "laptop"
A superseded call ends however the operation reacts to cancellation, which is usually an
OperationCanceledException. The caller of a superseded search should expect it and ignore it.
private readonly Func<string, CancellationToken, Task<string[]>> _searchLatest;
private async Task OnQueryChangedAsync(string query, CancellationToken cancellationToken)
{
try
{
string[] products = await _searchLatest(query, cancellationToken);
ShowResults(products);
}
catch (OperationCanceledException)
{
// A newer query replaced this one, or the page was closed.
}
}
The parameterless form fits a refresh button: pressing it again cancels the load that is still running.
Func<CancellationToken, Task<int>> countOpenInvoices = token => invoiceStore.CountOpenAsync(token);
Func<CancellationToken, Task<int>> refreshLatest = countOpenInvoices.Latest();
int openInvoices = await refreshLatest(cancellationToken);
Latest starts a request on every call. To also wait for the user to stop typing, start the operation with a short
delay that honors the token. A newer keystroke cancels the delay, so the old request is never sent.
Func<string, CancellationToken, Task<string[]>> search = async (query, token) =>
{
await Task.Delay(300.Milliseconds(), token);
return await productApi.SearchAsync(query, token);
};
Func<string, CancellationToken, Task<string[]>> searchLatest = search.Latest();
- The operation must honor the token it is given. If it ignores the token, the old call keeps running and its task completes normally.
- Calls that do not overlap cancel nothing.
- The linked token source of each call is disposed once that call completes.
- A
nulloperation throwsArgumentNullException(parameteroperation).
Rate-limiting actions
Debounce and Throttle wrap a plain Action with no arguments. If the action needs the latest state (the current
text, the newest progress value), let it read that state from a field instead of passing it in.
| First call in a burst | During the burst | After the burst | |
|---|---|---|---|
Debounce(delay) | Does not run | Restarts the quiet period | Runs once, delay after the last call |
Throttle(interval) | Runs right away | Merged into one pending run | Runs once more at the end of the interval |
Debounce
Debounce returns an action that runs once, delay after the last call in a burst. Each call restarts the quiet
period. Use it for auto-save, search boxes and file watchers, where only the final state matters.
int saves = 0;
Action saveDraft = () => Interlocked.Increment(ref saves);
Action saveSoon = saveDraft.Debounce(500.Milliseconds());
saveSoon(); // keystroke
saveSoon(); // keystroke
saveSoon(); // keystroke
// saves → 0 (nothing runs during the burst)
await Task.Delay(1.Seconds());
// saves → 1 (one run, about 500 ms after the last call)
- The action never runs on the calling thread, not even with a zero delay. It runs on a thread-pool thread.
- Two bursts separated by more than
delayproduce two runs. - A negative
delaythrowsArgumentOutOfRangeException(parameterdelay). Anullaction throwsArgumentNullException(parameteraction).
Throttle
Throttle returns an action that runs at most once per interval. The first call runs right away. Calls during the
interval are merged into one more run at the end of it, so the latest state is never missed. Use it for scroll and
resize handlers, progress reports and live dashboards.
int refreshes = 0;
Action refresh = () => Interlocked.Increment(ref refreshes);
Action throttledRefresh = refresh.Throttle(1.Seconds());
throttledRefresh(); // runs now, on this thread
throttledRefresh(); // merged into one trailing run
throttledRefresh(); // merged into the same trailing run
// refreshes → 1
await Task.Delay(1500.Milliseconds());
// refreshes → 2 (the trailing run, about one second after the first)
- The leading run happens on the calling thread, so an exception from it reaches the caller. The trailing run happens on a thread-pool thread.
- Calls spaced further apart than
intervaleach run immediately. Anintervalof zero runs every call immediately. - A negative
intervalthrowsArgumentOutOfRangeException(parameterinterval). Anullaction throwsArgumentNullException(parameteraction).
Debounced runs and trailing throttled runs happen on a thread-pool timer thread. No caller is there to catch an
exception, so an unhandled one terminates the process by default. Wrap the body of the action in try/catch and
log the error.
Running once
Once
Once wraps an Action so it runs only once; later calls do nothing. The Func<T> form runs once and every later
call returns the cached result. Use it for lazy initialization that must be safe to call from every entry point.
int runs = 0;
Action initialize = () => Interlocked.Increment(ref runs);
Action ensureInitialized = initialize.Once();
ensureInitialized();
ensureInitialized();
ensureInitialized();
// runs → 1
int reads = 0;
Func<int> readMaxUploadSizeMb = () =>
{
reads++;
return 25; // read from a file in real code
};
Func<int> maxUploadSizeMb = readMaxUploadSizeMb.Once();
int[] values = { maxUploadSizeMb(), maxUploadSizeMb(), maxUploadSizeMb() };
// values → [25, 25, 25]
// reads → 1
- Thread-safe. Calls that arrive while the first run is in progress wait for it. The
Func<T>form then returns the same result. - A run that throws does not count. The exception reaches the caller, and the next call tries again. After a successful run, nothing runs again.
- A
nulldelegate throwsArgumentNullException(parameteractionorfunction).
Func<T>.Once() is close to Lazy<T> with LazyThreadSafetyMode.ExecutionAndPublication, with one difference:
Lazy<T> caches an exception and throws it on every later access, while Once retries after a failure.
Building delegates
Negate
Negate returns the opposite predicate: true exactly when the original is false. It saves writing a lambda
just to add !, and it lets you keep one named predicate for both directions.
Func<string, bool> isBlank = string.IsNullOrWhiteSpace;
string[] names = { "Ada", " ", "", "Linus" };
string[] filled = names.Where(isBlank.Negate()).ToArray();
// → ["Ada", "Linus"]
A null predicate throws ArgumentNullException (parameter predicate).
Compose
Compose chains two functions into one that applies first and then second. parse.Compose(validate) is
input => validate(parse(input)). The intermediate type can differ from the input and output types.
Func<string, string> trim = text => text.Trim();
Func<string, int> parse = text => int.Parse(text, CultureInfo.InvariantCulture);
Func<string, int> parseQuantity = trim.Compose(parse);
int quantity = parseQuantity(" 12 "); // → 12
Func<string, string> describeQuantity = parseQuantity.Compose(count => $"{count} items");
string label = describeQuantity(" 3 "); // → "3 items"
The second function can be a lambda written in place, because only the receiver needs to be a delegate variable.
A null first or second throws ArgumentNullException with that parameter name.
Chaining calls
Pipe
Pipe passes a value to a function and returns the function's result. A chain of Pipe calls reads left to right,
in the order the steps run, instead of inside out: input.Pipe(Parse).Pipe(Validate) instead of
Validate(Parse(input)).
string orderNumber = " 1042 "
.Pipe(text => text.Trim())
.Pipe(text => int.Parse(text, CultureInfo.InvariantCulture))
.Pipe(number => $"ORD-{number + 1}");
// → "ORD-1043"
Pipeextends every type, and works onnullvalues:nullis passed to the function as is.- A
nullfunction throwsArgumentNullException(parameterfunction).
Tap
Tap runs a side effect on the value and returns the same value. Use it to log or record an intermediate step
inside a Pipe chain without breaking the chain.
List<decimal> audit = new List<decimal>();
decimal total = 120m
.Pipe(net => net * 1.16m)
.Tap(gross => audit.Add(gross))
.Pipe(gross => gross - 10m);
// total → 129.20m
// audit → [139.20m]
- For a reference type,
Tapreturns the same instance, so the action can change it in place:orders.Tap(list => list.Add(order))returnsorders. - A
nullaction throwsArgumentNullException(parameteraction).
Pipe and Tap are generic over any T and live in the System namespace, so they appear in IntelliSense on
every value.
See also
- Tasks:
WithTimeout, retries and other helpers for the async functions you wrap here. - Durations:
500.Milliseconds(),5.Minutes()and the otherTimeSpanhelpers used above. - Await anything:
awaiton a sequence of tasks, aTimeSpanand more. - Conventions: argument validation and
nullrules shared by the whole library.