重構改為複製清單後,呼叫方仍持有舊清單
Your Refactor Copies a List. The Caller Still Holds the Old One.
以參數快照建立 mutation ledger,可記錄函數呼叫前後的原地修改,避免只靠返回值測試漏掉呼叫方可見的變化。作者建議以記錄的 payload fixtures 產生穩定基線,再比較改動前後的修改路徑;LOST 表示原有修改消失,NEW 則代表新增修改。此方法無法偵測跨執行緒或程序的變化,深層快照亦有成本,對不透明物件可能無法可靠捕捉修改。
In-place mutation is the quietest part of a module's contract. Most characterization tests assert what a function returns. Almost none assert that the caller's list changed underneath. Replace rows.sort() with rows = sorted(rows) and the suite stays green. Production still reads the old list, in the old order.
This is the seam that survives a copy-versus-mutate refactor. Freeze it before you touch the body.
1. The break is aliasing, not logic
A caller passes a list. The function appends to it and returns None. The caller never reads the return value; it reads its own variable later. That is a real contract, and it lives outside your assertions.
Now a refactor returns a fresh list. Return-value tests pass. The caller keeps its stale object. Nothing raises. No error is logged.
The damage is silent because the signature did not change. So the gate must record mutations, not just outputs.
2. Build a mutation ledger
The probe below wraps a function. It deep-snapshots every argument before the call. It diffs each argument after the call and appends one JSON line per invocation.
# mutation_probe.py
import copy, functools, json
def _snapshot(value):
try:
return copy.deepcopy(value)
except Exception:
return repr(value)
def _diff(before, after, path="$"):
out = []
if type(before) is not type(after):
return [{"path": path, "change": "type",
"before": type(before).__name__, "after": type(after).__name__}]
if isinstance(before, dict):
for key in sorted(set(before) | set(after), key=str):
if key not in before:
out.append({"path": f"{path}[{key!r}]", "change": "added"})
elif key not in after:
out.append({"path": f"{path}[{key!r}]", "change": "removed"})
else:
out.extend(_diff(before[key], after[key], f"{path}[{key!r}]"))
return out
if isinstance(before, list):
if len(before) != len(after):
out.append({"path": path, "change": "length",
"before": len(before), "after": len(after)})
for i, (b, a) in enumerate(zip(before, after)):
out.extend(_diff(b, a, f"{path}[{i}]"))
return out
if before != after:
out.append({"path": path, "change": "value", "before": before, "after": after})
return out
def freeze_mutations(fn, ledger_path):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
before = {f"arg{i}": _snapshot(v) for i, v in enumerate(args)}
before.update({f"kw:{k}": _snapshot(v) for k, v in kwargs.items()})
result = fn(*args, **kwargs)
after = {f"arg{i}": _snapshot(v) for i, v in enumerate(args)}
after.update({f"kw:{k}": _snapshot(v) for k, v in kwargs.items()})
record = {"function": fn.__qualname__, "mutations": []}
for name in before:
record["mutations"].extend(_diff(before[name], after[name], name))
with open(ledger_path, "a", encoding="utf-8") as fh:
fh.write(json.dumps(record, sort_keys=True, default=repr) + "\n")
return result
return wrapper
Two notes before you run it. Snapshots cost memory, so target narrow functions. And default=repr keeps unserializable payloads from killing the run.
3. Capture against real call shapes
Tests alone give you a weak baseline. They exercise the arguments someone remembered to write.
- List candidate functions with
grep -rn "\.append(\|\.update(\|\.sort(\|\.pop(" src/. - Wrap the top candidates with
freeze_mutations(fn, "ledger-before.jsonl"). - Drive them with recorded payload fixtures, not synthetic ones.
- Run the suite twice and require identical ledgers, sorted by
(function, path, change). - Commit
ledger-before.jsonlas the baseline artifact.
Step 4 matters. A ledger that differs between runs is noise, not a contract. Fix the noise before you trust the diff.
4. Turn the ledger into a gate
The gate is a set comparison. It does not care about ordering across lines. It cares about which mutation paths exist.
# mutation_gate.py
import json, sys
def keys(path):
seen = set()
with open(path, encoding="utf-8") as fh:
for line in fh:
rec = json.loads(line)
for m in rec["mutations"]:
seen.add((rec["function"], m["path"], m["change"]))
return seen
baseline, current = keys(sys.argv[1]), keys(sys.argv[2])
lost, new = baseline - current, current - baseline
for tag, rows in (("LOST", lost), ("NEW", new)):
for fn_name, path, change in sorted(rows):
print(f"{tag}\t{fn_name}\t{path}\t{change}")
sys.exit(1 if lost or new else 0)
A LOST line is the dangerous one. A mutation that used to happen no longer happens. A NEW line is a contract you just invented.
5. Make the smallest safe change
One function per commit. Never two.
- Refactor a single function to return a new object.
- Re-run the probe into
ledger-after.jsonl. - Run the gate against the baseline.
- If
LOSTis non-empty, revert the copy and keep in-place mutation. - If only
NEWappears, delete it and re-check.
The revert in step 4 is not a failure. It is the gate doing its job.
6. Decide per mutation, not per module
| Observed mutation | Caller relies on it? | Action |
|---|---|---|
| Appends to a passed-in list | Yes, verified by reader code | Keep; document the contract in a docstring |
| Sorts a passed-in list | Unclear | Keep; add a test that asserts the caller's list order |
| Mutates a config dict | No | Convert to copy; expect LOST and accept it explicitly |
| Mutates an argument, returns same object | Yes | Convert only if you also update every reader |
| Mutates across a thread boundary | Unknown | Stop; the probe cannot see the race |
Run that table before you run the refactor. It converts a judgement call into a recorded decision.
7. Where a disposable box helps
Capture runs dirty the environment. They write ledgers, load fixtures, and sometimes touch a scratch database. Running them on your working checkout is friction you do not need.
MonkeyCode's free server option covers that part: one throwaway box for the capture run. Its free model access covers the drafting step, where a raw ledger becomes readable assertion names instead of JSON noise. Both are operator-supplied availability claims; check current limits yourself before planning around them.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Keep the gate in your own CI. Generated assertion names must be read against the ledger line by line. A model will happily invent a path that never mutated.
8. Limitations, and who should skip this
- List indices shift. A path like
arg0[2].idmoves when the list resizes. Normalize indices out before you compare across branches. - Opaque objects leak. Deep copies of database handles, sockets, or C extensions often fail, and
reprfallback hides the mutation. - Threads and processes are invisible. The probe sees one call, one thread, one moment.
- Side effects outside arguments are out of scope. Writes, network calls, and file handles need a separate gate.
- Deep snapshot is slow. Do not wrap hot paths, and do not wrap loops that run thousands of times per request.
Skip this approach if your code already treats data as immutable. Skip it if the object graph is too large to copy. Skip it if the mutation you fear lives in another process.
The rule
Asserting return values proves half the contract. Freeze what the function did to the arguments, or the quietest break ships.
來源:DEV Community · dev.to