Lazy values: optional work, one published outcome
A report may need customer names only when its caller requests detailed lines. Building an index on every call wastes work; rebuilding it for each line wastes more. A lazy value lets the application describe that optional work once and use the resulting dictionary normally.
lazy delays the complete initializer until the first ordinary value use. It does not start a background operation. The binding cannot be reassigned, but the result keeps its ordinary type and API. The language rules define this behavior independently of the private C# pattern below.
Build the index only when a report needs it
This complete example uses ordinary classes and .NET collections. It borrows both input sequences and returns collected report lines:
import Global.System
import Global.System.Collections.Generic
import Global.System.Collections.ObjectModel
public class ReportCustomer
public Code as String = ""
public Name as String = ""
end class
public class CustomerReport
public static action Preview(
customers as IEnumerable <of ReportCustomer>,
requestedCodes as IEnumerable <of String>,
includeNames as Boolean
)
returns List <of String>
require customers is not nothing
require requestedCodes is not nothing
let names = lazy BuildNames(customers)
let lines = new List <of String>()
lines.Add("Customer report")
if includeNames then
for_each code in requestedCodes
if names.TryGetValue(code, let name) then
lines.Add("{{code}}: {{name}}")
else
lines.Add("{{code}}: not found")
end if
end for_each
end if
return lines
end action
private static action BuildNames(
customers as IEnumerable <of ReportCustomer>
)
returns IReadOnlyDictionary <of String, String>
let names = new Dictionary <of String, String>(StringComparer.OrdinalIgnoreCase)
for_each customer in customers
let code = customer.Code ?? throw
names.Add(code, customer.Name ?? "Unnamed")
end for_each
return new ReadOnlyDictionary <of String, String>(names)
end action
end class
With customer C1 named Ana, requesting c1 and missing produces Customer report, c1: Ana and missing: not found. The explicit StringComparer.OrdinalIgnoreCase chooses the dictionary's data-key policy. Case-insensitive source names do not silently change an imported collection's comparer.
The input guards run at entry. If includeNames is false, neither sequence is enumerated. If it is true but no codes are requested, the customer sequence is still untouched. The first requested lookup builds the whole index; subsequent lookups reuse it. There is no Count prepass, background scheduling or cache shared between separate Preview calls.
BuildNames copies String names into a private dictionary and exposes a read-only view. Changing a customer's Name after initialization does not change the copied name. An empty name stays empty; actual absence uses Unnamed. Missing customers/codes and duplicate dictionary keys fail through the ordinary required-read/dictionary contracts. A duplicate key differing only in case therefore fails too. The application must choose a different duplicate policy explicitly if its business rules require one.
Captures are read when the value is forced
let rate = 10
let savedRate = rate
let currentAmount = lazy rate * 2
let savedAmount = lazy savedRate * 2
rate = 15
Global.System.Console.WriteLine(currentAmount) // 30
Global.System.Console.WriteLine(savedAmount) // 20
rate = 20
Global.System.Console.WriteLine(currentAmount) // still 30
The first initializer reads the mutable rate at its first use. The second uses an explicitly saved value. Successful initialization is cached, so later changes do not reset either binding. Calling an action still evaluates its arguments normally before entry; declaring a lazy local inside that action cannot delay argument evaluation at its caller.
A deferred closure does not extend a resource's usable lifetime. Capturing a reader and disposing it before first force can produce an initialization failure. Keep the first force within the resource's valid lifetime, or capture independent data while the resource is open. The lazy construct supplies no disposal, reset or retry protocol.
State inspection does not initialize the value
| Internal state | is initialized |
is not initialized |
is working |
is faulted |
|---|---|---|---|---|
| Not started | false | true | false | false |
| Working | false | true | true | false |
Initialized, including actual nothing |
true | false | false | false |
| Faulted | false | true | false | true |
Each predicate inspects state without executing the initializer or waiting for it to finish. A successful zero, false or nothing is a cached result, not evidence that work remains. Another thread can advance the state between separate observations; a state query is not a transaction around later application work.
An exception from the initializer is retained and rethrown on later forces. A failure in a consumer after successful initialization is different: a missing-key exception from a later dictionary operation does not turn the lazy value into a faulted initializer.
Reference C#: state, publication and recursive forcing
A factory-based System.Lazy<T> is useful for simple value/failure caching. Its public state surface does not directly supply all four DirectSharp observations. A backend also has to preserve transparent source use and reject recursive force dependencies clearly. This private synchronous pattern makes those duties explicit:
using System;
using System.Collections.Generic;
using System.Runtime.ExceptionServices;
using System.Threading;
internal static class LazyValueChecks
{
private enum LazyState { NotStarted, Working, Initialized, Faulted }
private abstract class LazyNode
{
internal LazyState State;
internal int OwnerThread;
}
private static class Coordinator
{
internal static readonly object Sync = new();
internal static readonly Dictionary<int, LazyNode> Waiting = new();
internal static bool WouldCycle(int reader, LazyNode target)
{
var visited = new HashSet<int>();
LazyNode? current = target;
while (current is not null && current.State == LazyState.Working)
{
int owner = current.OwnerThread;
if (owner == reader || !visited.Add(owner)) return true;
Waiting.TryGetValue(owner, out current);
}
return false;
}
}
private sealed class Deferred<T>(Func<T> factory) : LazyNode
{
private Func<T>? initializer = factory ?? throw new ArgumentNullException(nameof(factory));
private T cached = default!;
private ExceptionDispatchInfo? failure;
internal LazyState Snapshot
{
get { lock (Coordinator.Sync) return State; }
}
internal T Value
{
get
{
int reader = Environment.CurrentManagedThreadId;
Func<T> run;
lock (Coordinator.Sync)
{
while (State == LazyState.Working)
{
if (Coordinator.WouldCycle(reader, this))
throw new InvalidOperationException("Recursive lazy forcing.");
Coordinator.Waiting.Add(reader, this);
try { Monitor.Wait(Coordinator.Sync); }
finally { Coordinator.Waiting.Remove(reader); }
}
if (State == LazyState.Initialized) return cached;
if (State == LazyState.Faulted) failure!.Throw();
run = initializer!;
State = LazyState.Working;
OwnerThread = reader;
}
try
{
T result = run();
lock (Coordinator.Sync)
{
cached = result;
initializer = null;
OwnerThread = 0;
State = LazyState.Initialized;
Monitor.PulseAll(Coordinator.Sync);
}
return result;
}
catch (Exception error)
{
lock (Coordinator.Sync)
{
failure = ExceptionDispatchInfo.Capture(error);
initializer = null;
OwnerThread = 0;
State = LazyState.Faulted;
Monitor.PulseAll(Coordinator.Sync);
}
throw;
}
}
}
}
}
The first reader claims Working while holding the coordinator monitor, then runs the initializer outside it on that reader's thread. Other readers wait for publication. On success, the initialized payload is stored before the Initialized state and wake-up; even a default or null payload is complete. On failure, ExceptionDispatchInfo retains the original cause and stack for subsequent readers. The factory reference is cleared after either outcome. No user initializer runs while the coordinator monitor is held.
The four source predicates can read Snapshot and compare the enum: Initialized, anything other than Initialized, Working and Faulted respectively. Snapshot does not access Value. Generated ordinary value uses access Value internally; the DirectSharp source still writes names.TryGetValue(...), without exposing a wrapper or adding .Value.
The nongeneric coordinator deliberately spans different Deferred<T> types. While a thread waits, it records the lazy node it needs. Following that node's initializing thread and that thread's next wait detects direct recursion, indirect recursion and a cycle between participating initializing threads. Each edge is followed only while its node is Working: an already published node cannot create a stale dependency. Wait records are removed even when waiting fails.
This pattern chooses InvalidOperationException for recursive forcing; the language requires a clear recursion failure, without selecting that exact exception class. If that exception escapes an initializer, normal failure caching applies. The graph covers waits made through these private lazy nodes. Arbitrary native locks, opaque blocking calls and other external waits require separate analysis; this is not a promise of universal deadlock detection. The shared monitor is a reference strategy, not a selected runtime ABI or a performance claim.
Reference C#: the report operations
The following members belong inside the same containing class, with using System.Collections.ObjectModel; added above it. They implement the source example's ordinary .NET operations; the hidden deferred local is the only lazy-specific storage:
private sealed class ReportCustomer
{
internal string Code { get; set; } = "";
internal string Name { get; set; } = "";
}
private static IReadOnlyDictionary<string, string> BuildNames(IEnumerable<ReportCustomer> customers)
{
var names = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (ReportCustomer customer in customers)
{
string code = customer?.Code ?? throw new NullReferenceException();
names.Add(code, customer?.Name ?? "Unnamed");
}
return new ReadOnlyDictionary<string, string>(names);
}
private static List<string> Preview(
IEnumerable<ReportCustomer> customers, IEnumerable<string> requestedCodes, bool includeNames)
{
if (customers is null)
throw new ArgumentNullException(nameof(customers), "Customers are required: customers is not nothing");
if (requestedCodes is null)
throw new ArgumentNullException(nameof(requestedCodes), "Codes are required: requestedCodes is not nothing");
var names = new Deferred<IReadOnlyDictionary<string, string>>(() => BuildNames(customers));
var lines = new List<string>();
lines.Add("Customer report");
if (includeNames)
{
foreach (string code in requestedCodes)
{
if (names.Value.TryGetValue(code, out string? name))
lines.Add($"{code}: {name}");
else
lines.Add($"{code}: not found");
}
}
return lines;
}
This read-only dictionary is built from copied String values. Once-only initialization alone would not make an arbitrary returned List, dictionary or application object immutable or safe for concurrent mutation. The first force also has to see a valid input sequence: lazy publication does not make simultaneous changes to the source collection safe.
Checked cases and remaining implementation boundaries
The complete native C# fixture exercises the pattern with controlled threads:
| Case | Observed reference behavior |
|---|---|
| Unused value or state inspection | No initializer execution, including an initializer inspecting its own Working state |
| Mutable capture versus saved input | Force-time value versus explicitly retained earlier value; no later reset |
| Successful null, zero and false | Initialized, with the original result retained |
| Repeated and concurrent initializer failure | One execution, original failure retained and original stack preserved |
| Concurrent first successful reads | One execution, same fully initialized object published to every reader |
| Direct, indirect and cross-thread mixed-type cycles | Clear failure and settlement instead of waiting forever |
| Resource closed before first force | Original initialization failure retained; no lifetime extension |
| Consumer failure after initialization | Cached value remains successfully initialized |
| Optional report index | No unnecessary enumeration; one index snapshot reused across requested codes |
| Duplicate or missing customer data | Ordinary native failure/fallback policy preserved |
The fixture's dedicated threads and time bounds are test controls, not a lazy background-start policy. No async-lazy, cancellation, automatic task observation, retry or public helper API is selected by this account. Use the existing explicit async model and ordinary .NET APIs when the work itself is asynchronous.