3. Professional foundation and well-crafted functions
In this lesson we learn to work the way you work in a team and to write functions that can be reused and tested. It is the foundation everything else rests on.
Understanding the environment we set up
We take the environment setup from the previous lesson for granted. Here we don't review the how, but the why, which is what gets forgotten.
Why a virtual environment per project. "Isolating dependencies" sounds like jargon, but it is very concrete. Imagine project A needs version 2 of a library and project B version 5. If you install everything on the system, one of the two breaks. The virtual environment gives each project its own box of libraries, so neither bothers the other.
What the pyproject.toml stores. It is the project's data sheet. That is where the dependencies and their versions are recorded. When a colleague copies the project and runs uv sync, they get exactly the same versions as you. That is what makes the project reproducible, and what eliminates the classic "works on my machine".
Folder structure. We are going to organize the project like this:
metocean-tools/
├── pyproject.toml
├── src/
│ └── metocean_tools/
│ ├── __init__.py
│ └── waves.py
└── tests/
└── test_waves.py
The code lives in src/, the tests in tests/. Separating them is not a quirk: it makes clear what is product and what is scaffolding, and prevents you from accidentally packaging your tests when you distribute the tool.
Automatic formatting with Ruff. Formatting stops being your problem. You save, and Ruff places the spaces, the commas and the line breaks. Never again an argument about tabs versus spaces (it's four spaces, by the way, and the tool decides it, not you).
Python review with best practices
Before functions, four habits that make the difference between a script and professional code.
Names that explain themselves. A variable's name is free documentation. Compare:
# It's hard to know what this is
wh = 3.2
# There is no doubt
wave_height_m = 3.2
wave_height_m tells you what it measures (wave height) and in what unit (meters). In marine engineering, units kill: a value in feet where you expected meters is an expensive mistake. Put them in the name and save yourself the scares.
Choosing the right data structure. Each structure has its moment:
- List (
list): an ordered sequence that is going to change. The readings of a sensor over time. - Tuple (
tuple): an ordered sequence that does not change. A coordinate(latitude, longitude). - Dictionary (
dict): key-value pairs to look up by name. A record{"wave_height_m": 3.2, "wind_speed_kn": 18.0}. - Set (
set): unique elements without order, to check membership quickly. The identifiers of the buoys we have already processed.
Choosing the structure well saves you loops and errors. If you need "have I seen this buoy already?", a set answers you instantly; a list forces you to go through it entirely.
The hidden cost of global variables. A global variable is a variable that any part of the program can read and change. It sounds convenient, and it is a trap: when something goes wrong, you don't know who touched it or when. The code stops being predictable and the tests become a nightmare, because the result of a function depends on state you can't see in its signature. We will come back to this in the functional style lesson, because it is exactly the opposite of a pure function.
The PEP 8 style guide. PEP 8 is the Python community's agreement on how code is written: how many spaces, how things are named, where the line breaks go. You don't have to memorize it. Ruff knows it for you and applies it on save. But it is good to know it exists and why: when everyone writes the same way, reading someone else's code costs less.
Well-crafted functions
Here is the heart of the lesson. A well-crafted function is the basic unit of reusable and testable code. If you learn to write them well, half the course is won.
One function, one responsibility. If you have to use the word "and" to describe what a function does ("it validates and saves and notifies"), it is probably three functions. A function that does a single thing is easier to name, to test and to reuse.
Pure functions. This is the most important concept in the whole course, so stop here.
A pure function is one that, with the same input, always returns the same output, and that has no side effects (it doesn't touch external variables, doesn't write to disk, doesn't print, doesn't check the time or the network).
Why do we care so much about them? Because they are predictable. A pure function is a calculator: you put in some data, a result comes out, and always the same one. That makes it trivial to test (you give it known inputs and check known outputs) and trivial to reuse (it works the same here as there, because it doesn't depend on anything external).
Prefer functions to classes when you only need to transform data. If all you do is "a piece of data comes in, another comes out", you don't need a class with state. A pure function is simpler and more honest.
How many arguments a function should have. A rule that works well in practice:
- Ideal: 0 to 2 arguments.
- Acceptable: 3 or 4, if they are well justified or use default values.
- Bad sign: 5 or more. It usually indicates that the function does too much.
And what if I really need a lot of data? You have tools to avoid breaking the rule.
Group related arguments in a dataclass. Instead of dragging along a long list of loose parameters, you put them in a named record:
from dataclasses import dataclass
@dataclass
class SeaState:
"""Sea state at an instant.
Attributes:
wave_height_m: Wave height, in meters.
wind_speed_kn: Wind speed, in knots.
water_temperature_c: Water temperature, in degrees Celsius.
"""
wave_height_m: float
wind_speed_kn: float
water_temperature_c: float
def is_operational(sea_state: SeaState) -> bool:
"""Indicates whether work is possible with this sea state."""
return sea_state.wave_height_m < 2.5 and sea_state.wind_speed_kn < 20.0
The function receives a single object with everything it needs, and along the way the dataclass documents what each field is and in what unit. Here the class has no logic, it is a mere data record, which is exactly the use we give classes in this course.
Keyword-only arguments (with *). Everything that goes after a * in the signature must be passed by name. This makes the call read on its own:
def configure_alert(
parameter: str,
*,
warning_threshold: float,
critical_threshold: float,
) -> None:
"""Configures an alert for a buoy parameter."""
...
# You understand it without going to look at the signature
configure_alert("wave_height_m", warning_threshold=2.5, critical_threshold=4.0)
Without the *, someone could call configure_alert("wave_height_m", 2.5, 4.0) and you would have to go to the definition to know which threshold is which.
Default arguments and the danger of mutables. A default value is fine for the usual case. But there is a classic trap in Python: never use a mutable default value (a list, a dictionary). It is created only once and shared across all calls, with baffling results.
# BAD: the list is shared across calls
def append_reading(reading: float, readings: list = []) -> list:
readings.append(reading)
return readings
# GOOD: None as a sentinel and you create the list inside
def append_reading(reading: float, readings: list | None = None) -> list:
if readings is None:
readings = []
readings.append(reading)
return readings
*args and **kwargs are not an excuse to dodge design. It is tempting to put *args, **kwargs to "accept anything", but that only hides the arguments: whoever reads the signature no longer knows what the function expects. They are useful in specific places (decorators, wrappers, very generic APIs), not to avoid thinking about the design.
Clear return values versus hidden side effects. Prefer a function to return its result rather than hide it by modifying something behind your back. If a function calculates the average wave height, let it return it; don't have it store it in a global variable "so someone else can use it". Hidden effects are the origin of most hard bugs.
Type hints. They document what comes in and what goes out, and your editor uses them to warn you of errors before running:
def average_wave_height(wave_heights_m: list[float]) -> float:
...
At a glance you know that a list of floats comes in and a float comes out. It is not mandatory for Python to work, but it is a cheap safety net.
Docstrings. They describe the intent, they don't repeat the code. A good docstring explains the why and the units, not the how:
def average_wave_height(wave_heights_m: list[float]) -> float:
"""Calculates the average wave height of a series of readings.
Args:
wave_heights_m: Wave heights, in meters.
Returns:
The average height, in meters.
"""
...
Let Ruff watch for excess arguments. You can automate the arguments rule by adding this to the pyproject.toml:
[tool.ruff.lint]
select = ["PLR0913"]
From there Ruff warns you every time a function goes over five arguments. And if you prefer a stricter limit:
[tool.ruff.lint]
select = ["PLR0913"]
[tool.ruff.lint.pylint]
max-args = 4
Guided example: the average wave height
We are going to set up the metocean-tools project for real and turn a scattered calculation into a pure, well-annotated function.
Imagine you inherit a script that calculates the average wave height mixed with readings from a file, prints in between and a global constant. It is the typical case. We are going to extract the calculation into a pure function, in src/metocean_tools/waves.py:
def average_wave_height(wave_heights_m: list[float]) -> float:
"""Calculates the average wave height of a series of readings.
Args:
wave_heights_m: Wave heights, in meters.
Returns:
The average height, in meters.
"""
return sum(wave_heights_m) / len(wave_heights_m)
Notice what we have achieved: it doesn't read files, doesn't print, doesn't depend on any global constant. You give it a list of heights and it returns a number, always the same one. It is pure, it is reusable and, as we will see in the next lesson, it is a gift to test.
There is one loose end: what happens if the list is empty? For now the code would fail with a division error. We keep it noted in the back of our minds, because in the next lesson we are going to learn to handle that case elegantly.
This work is under a Attribution-NonCommercial-NoDerivatives 4.0 International license.
Desafíos de programación atemporales y multiparadigmáticos
Te encuentras ante un librillo de actividades, divididas en 2 niveles de dificultad. Te enfrentarás a los casos más comunes que te puedes encontrar en pruebas técnicas o aprender conceptos elementales de programación.
Buy the bookWill you buy me a coffee?
This is how I keep writing without ads or paywalls.
Sure, it's on me!
Comments
There are no comments yet.