Property getter returns a fresh copy of a collection or array

ID

vbnet.performance.no_copy_in_property_getter

Severity

high

Remediation Complexity

medium

Remediation Risk

medium

Remediation Effort

low

Resource

Performance

Language

VB.NET

Description

Reports a property whose Get accessor returns a newly built copy of a collection or an array: ToList(), ToArray(), Clone(), or a collection constructed from another (New List(Of T)(source), New Dictionary(Of K, V)(source)). Returning the backing field itself, exposing it through AsReadOnly(), or returning a scalar derived from it are all accepted, and the same expressions inside a method rather than a property are not reported - a method call sets a different expectation about cost.

Rationale

Callers read a property as if it were a field. Nothing at the call site distinguishes invoice.Lines from a field access, so the ordinary ways of using one are written without a second thought - inside a loop condition, indexed repeatedly, iterated more than once - and every one of those uses now allocates and copies the whole collection. For i = 0 To invoice.Lines.Count - 1 …​ invoice.Lines(i) turns a linear walk into a quadratic one, with a fresh copy per iteration, and nothing in the source hints at it. This is the kind of cost that does not appear in a profile as one hot method, only as diffuse allocation. The copy also changes semantics in a way that surprises callers twice over. Because each read produces a distinct object, mutating what the property returned has no effect on the invoice - invoice.Lines.Add(line) compiles, runs, and silently does nothing - and two reads are not reference-equal, so invoice.Lines Is invoice.Lines is False. Callers that cache the returned collection quietly get a snapshot that stops reflecting later changes.

The following code illustrates the pattern detected by this rule:

Private _amounts As Decimal() = New Decimal(9) {}

' FLAGGED: Property getter returns a fresh copy of a collection or array
Public ReadOnly Property Lines As List(Of InvoiceLine)
    Get
        Return _lines.ToList()
    End Get
End Property

' FLAGGED: Property getter returns a fresh copy of a collection or array
Public ReadOnly Property Tags As String()
    Get
        Return _tags.ToArray()
    End Get
End Property

' FLAGGED: Property getter returns a fresh copy of a collection or array
Public ReadOnly Property TagsCopy As List(Of String)
    Get
        Return New List(Of String)(_tags)
    End Get
End Property

' FLAGGED: Property getter returns a fresh copy of a collection or array
Public ReadOnly Property TagIndex As Dictionary(Of String, Integer)
    Get
        Return New Dictionary(Of String, Integer)(_index)

Remediation

Decide what the property is for and make it say so. If callers should be able to modify the collection, return the field itself. If the point of the copy was to stop callers modifying it, express that in the type rather than by copying: return IReadOnlyList(Of T) or IReadOnlyCollection(Of T), or wrap the field once in a ReadOnlyCollection(Of T) via AsReadOnly() and hold that wrapper in a field so the property allocates nothing. If a genuine snapshot really is the intent, make it a method - ToLineList(), ToArray() - so the cost is visible at the call site and callers stop treating it as a field.

' Before: every read allocates and copies, and callers' mutations are lost
Public ReadOnly Property Lines As List(Of InvoiceLine)
    Get
        Return _lines.ToList()
    End Get
End Property

' After: a wrapper built once, no allocation per read, immutability in the type
Private ReadOnly _linesView As ReadOnlyCollection(Of InvoiceLine) = _lines.AsReadOnly()

Public ReadOnly Property Lines As IReadOnlyList(Of InvoiceLine)
    Get
        Return _linesView
    End Get
End Property

' Or, when a snapshot is the point, make the cost visible
Public Function ToLineList() As List(Of InvoiceLine)
    Return _lines.ToList()
End Function

Configuration

This detector does not need any configuration.