Clean Functions
F1: Too Many Arguments (Maximum 3)
# Bad - too many parameters
def create_user(name, email, age, country, timezone, language, newsletter):
...
# Good - use a dataclass or dict
@dataclass
class UserData:
name: str
email: str
age: int
country: str
timezone: str
language: str
newsletter: bool
def create_user(data: UserData):
...
More than 3 arguments means your function is doing too much or needs a data structure.
F2: No Output Arguments
Don't modify arguments as side effects. Return values instead.
# Bad - modifies argument
def append_footer(report: Report) -> None:
report.append("\n---\nGenerated by System")
# Good - returns new value
def with_footer(report: Report) -> Report:
return report + "\n---\nGenerated by System"
F3: No Flag Arguments
Boolean flags mean your function does at least two things.
# Bad - function does two different things
def render(is_test: bool):
if is_test:
render_test_page()
else:
render_production_page()
# Good - split into two functions
def render_test_page(): ...
def render_production_page(): ...
F4: Delete Dead Functions
If it's not called, delete it. No "just in case" code. Git preserves history.
The Stepdown Rule: Code Reads Top-Down
A module should read like a narrative: the highest level of abstraction first, each function followed by the ones it calls. The reader descends one level at a time and can stop as soon as they know enough.
# Bad - the reader meets encoding details before knowing what the module does
def _escape_quotes(value: str) -> str: ...
def _serialise_row(row: Row) -> str: ...
def export_orders(orders: list[Order]) -> str: ... # the point, buried at the bottom
# Good - the story first, the details underneath
def export_orders(orders: list[Order]) -> str:
return "\n".join(_serialise_row(o) for o in orders)
def _serialise_row(row: Row) -> str:
return ",".join(_escape_quotes(field) for field in row)
def _escape_quotes(value: str) -> str:
return value.replace('"', '""')
Two consequences follow:
One level of abstraction per function. A function mixing orchestration with string escaping has no place in the ordering, because it belongs at two levels at once. That is the signal to split it.
# Bad - policy and byte-level detail in one breath
def publish(post: Post) -> None:
if post.author.is_banned: return
body = post.body.replace("\r\n", "\n").strip()[:5000]
db.execute("INSERT INTO posts ...", body)
# Good - each line is the same size of idea
def publish(post: Post) -> None:
if _is_publishable(post):
_store(post.author, _normalise(post.body))
Vertical distance tracks relatedness. Callee directly below caller. Variables declared close to first use. Public functions before the private helpers they call. A reader should not scroll to follow one thought.
If a file cannot be ordered this way, it holds more than one responsibility and wants splitting.