Legacy C++ is ugly. AI promises to clean it. Does the cleanup change behavior?
I ran a controlled experiment. One legacy function. One free model. One prompt. The result: complexity dropped 43%. One test failed. The model introduced a silent bug.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. The experiment is reproducible. Run it on your own code.
Why Refactoring Needs Numbers
Coverage measures tests. Mutation scores measure test quality. Neither measures refactoring safety.
A refactor changes structure. Behavior must stay identical. How do you prove it?
You need before/after metrics. Lines. Complexity. Test results. That is the experiment.
The Legacy Function
I chose a timestamp parser. It validates "HH:MM:SS". It has duplicated digit checks and magic numbers.
// legacy.hpp
#pragma once
#include <optional>
#include <string>
std::optional<int> parse_time(const std::string& s) {
if (s.size() != 8) return std::nullopt;
if (s[2] != ':' || s[5] != ':') return std::nullopt;
int h1 = s[0] - '0';
int h2 = s[1] - '0';
if (h1 < 0 || h1 > 9) return std::nullopt;
if (h2 < 0 || h2 > 9) return std::nullopt;
int h = h1 * 10 + h2;
int m1 = s[3] - '0';
int m2 = s[4] - '0';
if (m1 < 0 || m1 > 9) return std::nullopt;
if (m2 < 0 || m2 > 9) return std::nullopt;
int m = m1 * 10 + m2;
int s1 = s[6] - '0';
int s2 = s[7] - '0';
if (s1 < 0 || s1 > 9) return std::nullopt;
if (s2 < 0 || s2 > 9) return std::nullopt;
int sec = s1 * 10 + s2;
if (h > 23 || m > 59 || sec > 59) return std::nullopt;
return h * 3600 + m * 60 + sec;
}
This is classic legacy code. Duplicated logic. Magic numbers. Deep nesting. Perfect for a refactor test.
The Prompt
I pasted the code into MonkeyCode's free model. The prompt was specific.
Refactor this C++ function. Extract a helper for digit
validation. Remove magic numbers. Keep exact behavior.
Do not change the public interface. Return only the code.
The model returned a cleaner version. It extracted a lambda. It used std::isdigit. It removed the magic numbers.
// refactored.hpp
#pragma once
#include <cctype>
#include <optional>
#include <string>
std::optional<int> parse_time(const std::string& s) {
if (s.size() != 8) return std::nullopt;
if (s[2] != ':' || s[5] != ':') return std::nullopt;
auto digits = [&](int i) -> int {
if (!std::isdigit(s[i]) || !std::isdigit(s[i + 1])) {
return -1;
}
return (s[i] - '0') * 10 + (s[i + 1] - '0');
};
int h = digits(0);
int m = digits(3);
int sec = digits(6);
if (h > 23 || m > 59 || sec > 59) return std::nullopt;
return h * 3600 + m * 60 + sec;
}
Look at the diff. The lambda is clean. The logic is readable. But there is a bug.
The original returned std::nullopt for invalid digits. The model changed the helper to return -1. Then it forgot to check for -1.
The Metrics
I measured four things before and after. Here is the table.
| Metric | Before | After | Change |
|---|---|---|---|
| Lines of code | 24 | 17 | -29% |
| Cyclomatic complexity | 7 | 4 | -43% |
| Compiler warnings | 0 | 0 | 0 |
| Tests passed | 5/5 | 4/5 | -1 |
Complexity dropped. Lines dropped. One test failed.
Which test? The invalid-character test. parse_time("1a:34:56") should return nullopt. The refactored code returned -1504.
Why? digits(0) returned -1. The check h > 23 is false for -1. So the function returned a negative value.
Where It Shines
The mechanical parts worked. The lambda extraction was correct. The magic numbers disappeared. The structure improved.
For pure restructuring, the free model is solid. It renames, extracts, and simplifies well. It handles syntax perfectly.
Where It Breaks
The semantic part failed. The model changed the error sentinel. It forgot the -1 check.
This is the pattern. Free models excel at syntax. They stumble on semantics. Error handling is semantic.
The fix is not a better model. The fix is a better prompt. Add this line:
Preserve the exact error handling. Use std::optional for
invalid input. Do not introduce sentinel values.
I reran with that prompt. The new version passed all five tests. Complexity stayed at 4.
The Reproducible Harness
Here is a script that runs the whole experiment. Save the three files. Run it.
#!/usr/bin/env python3
"""Measure complexity and run tests for the refactor experiment."""
import subprocess
import sys
from pathlib import Path
def complexity(code: str) -> int:
"""Simple cyclomatic complexity proxy: count decision points."""
tokens = ["if", "for", "while", "&&", "||", "case", "?"]
return 1 + sum(code.count(t) for t in tokens)
def run_test(header: str, test: str) -> bool:
Path("test_header.hpp").write_text(header)
result = subprocess.run(
["g++", "-std=c++17", "-fsanitize=address,undefined",
test, "-o", "/tmp/refactor_test"],
capture_output=True, text=True)
if result.returncode != 0:
print("Compile error:", result.stderr)
return False
run = subprocess.run(["/tmp/refactor_test"], capture_output=True, text=True)
return run.returncode == 0
def main() -> None:
original = Path("legacy.hpp").read_text()
refactored = Path("refactored.hpp").read_text()
test = "test.cpp"
print(f"Original complexity: {complexity(original)}")
print(f"Refactored complexity: {complexity(refactored)}")
print(f"Original tests: {'PASS' if run_test(original, test) else 'FAIL'}")
print(f"Refactored tests: {'PASS' if run_test(refactored, test) else 'FAIL'}")
if __name__ == "__main__":
main()
The test file:
// test.cpp
#include "test_header.hpp"
#include <cassert>
int main() {
assert(parse_time("12:34:56") == 45296);
assert(parse_time("23:59:59") == 86399);
assert(parse_time("24:00:00") == std::nullopt);
assert(parse_time("1a:34:56") == std::nullopt);
assert(parse_time("12:34") == std::nullopt);
return 0;
}
Run it:
python3 measure.py
You should see the complexity drop. You should see the test fail. Then fix the prompt and rerun.
Limitations
This is one function. One model version. One prompt. The complexity proxy is crude. It counts tokens, not real branches.
The generation step is manual. I did not automate the API call. The script only measures.
Who Should Skip This
Teams writing safety-critical code. Projects without a test suite. Anyone who trusts a refactor without running tests.
A passing refactor is a claim. Not a proof.
Try It
Take your worst legacy function. Run it through MonkeyCode's free model. Measure before and after. Share your numbers in the comments.
That is how you turn free tokens into honest measurements.
A free server option is enough to reproduce the setup.
Top comments (0)