C# ref local: aliasing variables without copying
c# ref local: Learn how C# ref local variables alias existing storage, avoid copies, and work with ref returns. Understand restrictions and when to use them.
When you write ref int r = ref numbers[0]; in C#, you are not creating a copy of the value. You are creating a second name for the same storage location. This is the core idea behind a C# ref local: a local variable that aliases another variable, allowing you to read and modify the original data without copying it.
Ref locals are part of the broader ref feature set that includes ref returns and ref parameters. They exist to give developers fine-grained control over memory access, particularly when working with large structs or performance-sensitive code paths. Understanding how they behave and where they are allowed is essential before using them in production code.
What a ref local actually is
A ref local does not hold a value. It holds a reference to a storage location. That storage location can be an array element, a field, a local variable, or even a ref return from a method. When you assign to a ref local, you are writing to the original location. When you read from it, you are reading the current value at that location.
This aliasing behavior is different from a regular local variable, which has its own storage and copies the value on assignment. With a ref local, there is only one storage location, and the ref local is just another way to reach it.
The compiler enforces that a ref local is always initialized to a valid storage location. You cannot declare a ref local without an initializer, and the initializer must be a variable, a field, an array element, or a ref return. This ensures that the ref local never dangles.
Declaring a ref local
The syntax is straightforward. You use the ref keyword in both the declaration and the initializer. Here is a minimal example that swaps two elements in an array without copying them:
int[] numbers = { 10, 20, 30 }; ref int first = ref numbers[0]; ref int second = ref numbers[1]; int temp = first; first = second; second = temp;
After this code runs, numbers[0] is 20 and numbers[1] is 10. The ref locals first and second are not copies; they are aliases for the array elements. The swap works directly on the array storage.
You can also create a ref local that aliases a field or another local variable:
int value = 5; ref int alias = ref value; alias = 10; Console.WriteLine(value); // Output: 10
Here alias and value refer to the same storage. Changing alias changes value.
Ref locals with ref returns
Ref locals become particularly useful when combined with ref returns. A method can return a reference to one of its internal fields or an array element, and the caller can store that reference in a ref local. This allows the caller to read or modify the returned storage without copying it.
Consider a simple collection that exposes a ref return to an element by index:
class Storage { private int[] _items = new int[100]; public ref int GetItem(int index) { return ref _items[index]; } }
The caller can then use a ref local to work with the actual element:
Storage storage = new Storage(); ref int item = ref storage.GetItem(42); item = 99;
This writes 99 directly into _items[42]. Without ref returns, you would need to read the value, modify it, and write it back. For a large struct, that would involve copying the entire struct twice. With a ref local, no copy occurs.
This pattern is common in high-performance collections and algorithms where avoiding struct copies matters. It is also useful when you need to pass a reference to a method that expects a ref parameter, but you want to avoid creating an intermediate copy.
Restrictions and safety rules
The C# compiler imposes strict rules on ref locals to guarantee memory safety. These restrictions are not arbitrary; they exist to prevent references from outliving the storage they point to.
A ref local cannot be used inside an async method. The reason is that async methods may suspend and resume, and the compiler cannot guarantee that the referenced storage remains valid across the suspension points. Even if the storage is a local variable, the async state machine moves locals to the heap, and a ref cannot point to heap-allocated state machine fields.
Ref locals also cannot be captured by a lambda or a local function. Capturing a ref local would require the closure to store a reference, which could outlive the original storage. The compiler rejects this at compile time.
Similarly, ref locals cannot be used in iterator methods (yield return). Iterators are lazily executed, and the storage may not be valid when the iterator is resumed. The compiler forbids this pattern.
You also cannot use a ref local as a field in a class or struct. Ref locals are strictly local variables. Their lifetime is tied to the scope in which they are declared.
These restrictions are consistent across all recent C# versions. If you try to use a ref local in a forbidden context, you will get a compiler error such as CS8170 or CS8175, depending on the exact situation.
Performance and memory implications
The main motivation for using a ref local is to avoid copying large structs. When you pass a struct by value, the runtime copies the entire struct. For a struct that contains several fields, this copy cost can be significant in tight loops or high-throughput code.
A ref local does not eliminate the copy cost by itself. It only helps when the original storage is accessed through the ref local instead of copying it. For example, if you have a large struct in an array and you want to update one field, you can use a ref local to modify the field directly:
struct LargeStruct { public int A; public int B; public int C; // ... many more fields } LargeStruct[] items = new LargeStruct[1000]; ref LargeStruct item = ref items[0]; item.A = 42;
Without the ref local, you would need to write:
LargeStruct copy = items[0]; copy.A = 42; items[0] = copy;
This copies the struct twice. The ref local version avoids both copies.
That said, the JIT compiler may already optimize simple cases. If the struct is small and fits in registers, the copy cost may be negligible. The performance benefit of ref locals is most visible with large structs or when the access pattern is repeated many times.
There is also a memory consideration. Ref locals themselves do not allocate memory on the heap. They are implemented as managed pointers, which are tracked by the runtime. The runtime must ensure that the referenced storage is not moved by the garbage collector. For arrays and fields on the heap, this adds some bookkeeping overhead. In practice, this overhead is small compared to the cost of copying a large struct, but it is not zero.
When to use ref locals and when to avoid them
Ref locals are a specialized tool. Use them when you have a concrete, measured need to avoid copying large structs in performance-critical code. Typical scenarios include:
- Implementing high-performance collections that expose ref returns.
- Writing algorithms that repeatedly read and modify the same array element or field.
- Interoperating with code that expects
refparameters and you want to pass a reference to an existing storage location.
Avoid ref locals when readability and maintainability are more important than raw performance. Ref locals make the data flow less obvious. A developer reading the code must understand that ref int x is not a copy and that changes to x affect the original variable. This mental overhead can be justified in a hot path, but it is not worth it in regular business logic.
Also avoid using ref locals just to avoid copying a small struct. The JIT can often optimize away the copy, and the added complexity is not justified. Profile first. If you cannot demonstrate that copying is a bottleneck, a ref local is likely premature optimization.
When you do use a ref local, keep its scope as small as possible. This reduces the chance of accidentally aliasing the wrong storage and makes the code easier to reason about.
Common mistakes and edge cases
One common mistake is trying to reassign a ref local to a different storage location after initialization. A ref local can be reassigned, but the reassignment must also use the ref keyword:
int a = 1; int b = 2; ref int r = ref a; r = ref b; // Now r aliases b
If you write r = b; without ref, you are copying the value of b into the storage that r points to, which is a. This subtle difference is a frequent source of bugs.
Another edge case is aliasing a field of a struct that is itself a field of a class. The compiler allows this, but you must ensure that the containing object is not null. For example:
class Container { public LargeStruct Data; } Container container = new Container(); ref LargeStruct data = ref container.Data; data.A = 10;
This works because container.Data is a field on the heap, and the ref local points to that field. The runtime tracks the reference to prevent the container from being collected while the ref local is alive.
A less obvious edge case is using a ref local to alias a property. Properties are methods, not storage locations. You cannot write ref int r = ref obj.Property; because properties do not have a storage location. The compiler will reject this. Only fields, array elements, and local variables are valid targets.
Finally, be careful when mixing ref locals with ref parameters in methods. If you pass a ref local as a ref argument, the method can reassign the ref local to point to a different storage location. This is allowed, but it can be surprising if you did not expect the method to change the alias. The method signature should clearly indicate whether it reassigns the ref parameter.
Understanding these edge cases is important because the compiler will not always warn you about the semantic consequences. It enforces syntax and lifetime rules, but it cannot tell you whether aliasing is logically correct for your algorithm. That judgment is yours.