No Copy In Property Getter

ID

csharp.no_copy_in_property_getter

Severity

high

Remediation Complexity

trivial

Remediation Risk

low

Remediation Effort

low

Resource

Efficiency

Language

CSharp

Tags

api-design, collection, efficiency, performance

Description

Reports a property getter whose returned value is a fresh copy of a collection or array, built by ToList(), ToArray(), an array Clone(), or the copy constructor of a mutable collection such as new List<T>(other). Callers treat a property read as field-cheap; a copy per read is neither cheap nor shared.

Rationale

A property reads like a field, so callers use it freely — inside a loop, inside a condition, twice on the same line. When the getter copies, each of those reads allocates and walks the whole collection. A loop that iterates Items.Count times and touches Items in its body becomes quadratic, and nothing at the call site hints at it.

The correctness side is worse than the cost. Two reads of the same property return two different objects, so reference equality between them is false, and a caller who adds to what the property returned mutates a throwaway copy — the change silently disappears. Both bugs are invisible at the call site, because nothing in x.Items suggests that a new list was just built.

Only the copying shapes above are reported, and only when the copy is part of what the getter returns — so a copy cached in a lazily initialised backing field is left alone, while a copy handed straight to a wrapper (lines.ToList().AsReadOnly()) is not. The same call in an ordinary method is fine — a method call does not promise to be cheap.

Two guards keep the report on genuine copies. Clone() counts only when the receiver is an array, so a user type implementing ICloneable is not mistaken for one. And a constructor counts only when its single argument is itself a collection or array: new List<T>(10) reserves capacity for an empty list, and a wrapper such as new ReadOnlyCollection<T>(lines) copies nothing at all.

public class Order
{
    private readonly List<string> lines = new List<string>();
    private readonly int[] codes = new int[4];
    private List<string> cached;

    public List<string> Lines => lines.ToList();          // FLAW — a copy on every read

    public List<string> Copy => new List<string>(lines);   // FLAW — the copy constructor

    public int[] Codes
    {
        get { return (int[])codes.Clone(); }               // FLAW — a copy on every read
    }

    public IReadOnlyList<string> ReadOnlyLines => lines;   // OK — a view, not a copy

    public List<string> Sized => new List<string>(16);     // OK — a capacity, not a copy

    public List<string> Cached
    {
        get
        {
            if (cached == null) cached = lines.ToList();    // OK — copied once, not per read
            return cached;
        }
    }

    public List<string> Snapshot() => lines.ToList();       // OK — a method advertises the cost
}

Remediation

Expose a view instead of a copy: type the property as IReadOnlyList<T> or IReadOnlyCollection<T>, or wrap the backing collection once with AsReadOnly() and hand out that wrapper. If callers genuinely need an independent copy, move the copy into a method whose name says so — Snapshot(), ToLineArray() — so the allocation is visible where it is paid for. If the copy is only there to keep the backing store private, a read-only interface achieves the same protection at no per-read cost.