This section enumerates the hard constraints and limitations of InlineCollections that developers must understand before adoption.
Hard limit: Exactly 8, 16, or 32 elements (depending on collection variant). Attempting to exceed throws InvalidOperationException.
var list = new InlineList16<int>();
for (int i = 0; i < 17; i++) {
list.Add(i); // ❌ Throws InvalidOperationException on i=16
}Capacity options:
InlineList8<T>,InlineStack8<T>,InlineQueue8<T>: 8 elements maxInlineList16<T>,InlineStack16<T>,InlineQueue16<T>: 16 elements maxInlineList32<T>,InlineStack32<T>,InlineQueue32<T>: 32 elements max
Impact:
- Collections cannot grow dynamically
- Capacity planning required upfront
- Unsuitable for unbounded workloads
- Size selection impacts stack usage (e.g.,
InlineList32<int>uses ~132 bytes,InlineList8<int>uses ~36 bytes)
Recommendation: Profile and measure. If you consistently need more than 32 elements, use List<T>. If you consistently need fewer than 8, use InlineList8<T> to minimize stack usage.
Constraint: T : unmanaged, IEquatable<T>
Unmanaged means:
- No managed object references (no string, no class instances)
- Primitive types: int, long, float, double, byte, etc.
- Unmanaged structs: all fields must be unmanaged
Invalid examples:
var list = new InlineList32<string>(); // ❌ string is managed
var list = new InlineList32<object>(); // ❌ object is managed
var list = new InlineList32<List<int>>(); // ❌ nested reference type
struct WithRef { string Name; } // ❌ Contains managed field
var list = new InlineList32<WithRef>();Valid examples:
var list = new InlineList32<int>(); // ✅ int is unmanaged
var list = new InlineList32<Guid>(); // ✅ Guid is unmanaged struct
var list = new InlineList32<Point>(); // ✅ struct Point { int x, y; }
struct Point {
public int X, Y; // Both unmanaged
}Why: Inline storage and stack allocation require GC-safe memory. Managed references must be traced by GC.
Ref struct rules:
- Cannot be stored in class fields
- Cannot be boxed
- Cannot be used in async/await
- Cannot be captured in closures that outlive the frame
Stack usage considerations:
InlineList8<int>: ~36 bytesInlineList16<int>: ~68 bytesInlineList32<int>: ~132 bytes- Queue variants are larger due to additional head/tail fields
- Declare collections carefully in deeply nested calls to avoid stack overflow on large element types
Warning: The struct size is Capacity × sizeof(T). Using a large element type or copying the collection by value across many frames can create significant stack pressure and may trigger a
StackOverflowException. Consider passing the collection byref/in, or using a smaller variant or heap-based alternative.
Invalid examples:
class Container {
public InlineList8<int> List; // ❌ Cannot store in class
}
object boxed = new InlineList16<int>(); // ❌ Cannot box
async Task ProcessAsync() {
var list = new InlineList32<int>();
await SomeAsync(); // ❌ list cannot escape to async
}Valid examples:
var list = new InlineList8<int>(); // ✅ Local variable
void Method(ref InlineList16<int> list) { ... } // ✅ ref parameter
## Value-type copying cost
**Constraint**: Assignments and parameter passing copy the entire struct.
```csharp
var list1 = new InlineList32<int>();
list1.Add(1);
var list2 = list1; // COPY: 132 bytes for InlineList32<int>
list2.Add(2);
// list1 and list2 are independent
Copy cost: ~3 nanoseconds for InlineList32<int> on modern CPUs, but scales with element type:
| Element type | Struct size | Copy time |
|---|---|---|
| int (4B) | 132B | ~3ns |
| long (8B) | 260B | ~6ns |
| Guid (16B) | 516B | ~12ns |
Impact: In tight loops with large element types, copying overhead can negate allocation savings.
Recommendation: Use ref parameters in hot paths:
// ❌ Copies struct
void Process(InlineList32<int> list) { list.Add(99); }
// ✅ No copy
void Process(ref InlineList32<int> list) { list.Add(99); }Constraint: Each collection on the stack occupies memory. Deep recursion or many collections in a frame can cause stack overflow.
void DeepRecursion(int depth) {
var list = new InlineList32<int>(); // 132 bytes
var stack = new InlineStack32<int>(); // 132 bytes
var queue = new InlineQueue32<int>(); // 140 bytes
// Frame: ~404 bytes
if (depth < 10000)
DeepRecursion(depth + 1); // Each recursive call adds 404 bytes
}Typical stack: 1 MB on .NET Max depth: ~2500 levels before overflow
Recommendation: Profile stack usage. For deep recursion, consider List<T> or heap-based structures.
Constraint: Indexer and unsafe methods (Add, Push, Enqueue) do not bounds check.
var list = new InlineList32<int>();
list.Add(42);
int value = list[1]; // ❌ Out of bounds! Undefined behaviorMitigation: Use Try- variants:
if (list.TryAdd(42)) {
// Safe
} else {
// Handle full
}Constraint: Large element types increase struct size and copy cost.
struct BigData { public long[100] Data; } // Managed array! Invalid.
struct BigStruct {
public double A, B, C, D, E, F, G, H; // 64 bytes
}
// InlineList32<BigStruct> is 32*64+4 = 2052 bytes
var list = new InlineList32<BigStruct>();
// Stack frame usage: 2KB just for this collection!Recommendation: Prefer small element types (int, long, small structs). If element size > 8 bytes, evaluate whether allocation cost of List<T> is acceptable.
Constraint: Collections are not thread-safe. Concurrent access is undefined behavior.
var list = new InlineList32<int>();
Task.Run(() => list.Add(1));
Task.Run(() => list.Add(2));
// ❌ Race condition, possible corruptionMitigation: Use locks or concurrent collections:
var lockObj = new object();
lock (lockObj) {
list.Add(1); // ✅ Protected
}Exceptions thrown:
| Exception | Method | Condition |
|---|---|---|
InvalidOperationException "capacity exceeded (32)" |
Add, Enqueue, Push | Count == 32 |
InvalidOperationException "Stack Empty" / "Queue Empty" |
Pop, Dequeue, Peek | Count == 0 |
ArgumentOutOfRangeException |
RemoveAt | Invalid index |
No silent failures: All error conditions throw exceptions or return false (Try- variants).
Constraint: Ref structs cannot be used across await boundaries.
async Task ProcessAsync() {
var list = new InlineList32<int>();
list.Add(1);
await Task.Delay(1); // ❌ Compiler error: ref struct cannot cross await
}Workaround: Convert to array:
async Task ProcessAsync() {
var list = new InlineList32<int>();
list.Add(1);
int[] array = list.AsSpan().ToArray(); // Copy to managed array
await Task.Delay(1); // ✅ OK, array is managed
}Constraint: Modifying collection during enumeration causes undefined behavior.
var list = new InlineList32<int>();
list.Add(1); list.Add(2);
foreach (var item in list) {
list.Add(3); // ❌ Modifying during enumeration
}Mitigation: Snapshot or use manual loop:
var snapshot = list.AsSpan().ToArray();
foreach (var item in snapshot) {
list.Add(3); // ✅ Safe
}Constraint: Ref returns must not outlive the source collection.
ref int GetRef(InlineList32<int>& list) { // ❌ Compiler prevents this
return ref list[0];
}The compiler statically prevents this pattern. Ref returns cannot escape stack scope.
| Scenario | Recommended size | Rationale |
|---|---|---|
| Small collections | ≤ 16 elements | Conservative stack usage |
| Moderate depth recursion | 5-10 collections | ~400-600 bytes/frame |
| Deep recursion | Consider List | Stack pressure |
| Large element types (>16B) | Reconsider approach | Copy cost dominates |
- Collections often exceed 32 elements
- Managed types (string, class) required
- Thread-safety essential
- Dynamic capacity needed
- API compatibility with List required
- Async/await usage across collection lifetime
- Stack depth > 5000 levels