No Side Effect In Assert
ID |
csharp.no_side_effect_in_assert |
Severity |
high |
Remediation Complexity |
medium |
Remediation Risk |
low |
Remediation Effort |
medium |
Resource |
Code Smell |
Language |
CSharp |
Tags |
assertion, code_smell, diagnostics, side-effect |
Description
Reports a Debug.Assert(…) whose condition changes program state: an assignment, an
increment or decrement, or a call to a member whose name means mutation.
Rationale
Debug.Assert is declared with Conditional("DEBUG"). When the DEBUG symbol is not
defined, the compiler emits no call at all — and, because the whole call site disappears, no
argument is evaluated either. Anything the condition did to program state simply stops
happening.
The result is a program that behaves one way under the configuration the developer tested and another way in the configuration that ships. Such a difference is hard to reproduce, because the only build that shows the bug is the one without the assertions.
Assignments and increments are certain mutations. Calls are judged by name only. A member is
matched on its first camel-case word against the mutating verbs — Add, Append, Clear,
Delete, Dequeue, Dispose, Enqueue, Insert, Pop, Push, Put, Remove, Retain,
Set, Update — plus MoveNext and Next, so AddItem, PushBack and UpdateEntry are
caught along with the bare verb, while Settings and AddressOf are not. SetEquals is
excluded: it reads as a mutation but is a set comparison and belongs in an assertion. Every other
call is left alone so that ordinary predicates inside an assertion are not reported.
using System.Collections.Generic;
using System.Diagnostics;
public class Cart
{
private readonly List<string> items = new List<string>();
private int checks;
public void Drop(string sku)
{
Debug.Assert(items.Remove(sku)); // FLAW — in release the item is never removed
Debug.Assert(++checks < 100); // FLAW — the counter stops advancing in release
}
public void DropChecked(string sku)
{
bool removed = items.Remove(sku); // OK — the mutation happens in every build
Debug.Assert(removed); // OK — the condition only reads
Debug.Assert(items.Count >= 0); // OK — a property read is not a mutation
}
}
A mutating call is not reported when the enclosing method is itself gated behind
[Conditional(…)] and the mutated receiver is a local variable declared inside that same
method — the whole method already disappears from a non-debug build, so there is no
differential behavior left to warn about. A field mutated the same way is still reported,
because the field stays visible to other, non-conditional code:
[Conditional("DEBUG")]
private void ValidateLocalOnly()
{
using var seen = new HashSet<string>();
Debug.Assert(seen.Add("x")); // OK — seen never leaves this Conditional-gated method
}
[Conditional("DEBUG")]
private void ValidateLeaksField()
{
Debug.Assert(sharedSet.Add("x")); // FLAW — sharedSet is a field, its mutation is visible elsewhere
}
Remediation
Move the state change out of the assertion and assert over the result. Assign the call’s value
to a local, or perform the mutation on its own statement, then pass the plain condition to
Debug.Assert. Where the check itself must survive a release build, use a normal if with an
explicit throw instead of an assertion.