02 - Fonctions : courtes, claires, responsables
Ce que tu vas apprendre
- Quelle taille devrait avoir une fonction
- La regle de responsabilité unique appliquee aux fonctions
- La regle des 3 paramètres maximum
- Le pattern early return pour aplatir les conditions
- La différence entre fonctions pures et fonctions avec effets de bord
Prerequisites
J'ai deja vu une fonction de 1 400 lignes. Pas dans un projet etudiant. Dans un projet en production utilise par 50 000 personnes. Elle s'appelait handleRequest. Elle gerait l'authentification, la validation, le traitement métier, l'envoi d'emails, la mise en cache, le logging et la réponse HTTP. Tout dans un seul bloc. Un collegue l'avait surnommee "la fonction dieu".
Pour corriger un bug dans le calcul de TVA, il fallait comprendre les 1 400 lignes. Parce que des variables declarees a la ligne 47 etaient utilisees a la ligne 1 380. Parce qu'un if a la ligne 200 changeait le comportement a la ligne 900. Le debugger n'aidait meme plus, il fallait tenir le contexte en tête.
Ce jour-la, j'ai compris que la taille d'une fonction est un sujet serieux.
Quelle taille pour une fonction ?
Robert C. Martin recommande 5 a 15 lignes. C'est un ideal, pas un dogme. En pratique, je vise moins de 30 lignes pour le code applicatif et je toléré jusqu'a 50 pour des fonctions de mapping ou de transformation de donnees.
La vraie question n'est pas "combien de lignes" mais "combien de choses". Une fonction qui fait une seule chose en 40 lignes est meilleure qu'une fonction qui fait trois choses en 15 lignes.
typescript// Trop de choses dans une seule fonction
async function handleOrder(req: Request): Promise<Response> {
// Validation (chose 1)
if (!req.body.items || req.body.items.length === 0) {
return new Response("No items", { status: 400 });
}
for (const item of req.body.items) {
if (!item.productId || item.quantity <= 0) {
return new Response("Invalid item", { status: 400 });
}
}
// Calcul du prix (chose 2)
let total = 0;
for (const item of req.body.items) {
const product = await db.products.findById(item.productId);
if (!product) return new Response("Product not found", { status: 404 });
total += product.price * item.quantity;
}
const tax = total * 0.2;
const finalTotal = total + tax;
// Sauvegarde (chose 3)
const order = await db.orders.create({
items: req.body.items,
total: finalTotal,
tax,
});
return Response.json(order, { status: 201 });
}
Trois responsabilités distinctes. Decomposons :
typescriptasync function handleOrder(req: Request): Promise<Response> {
const validationError = validateOrderItems(req.body.items);
if (validationError) {
return new Response(validationError, { status: 400 });
}
const pricing = await calculateOrderTotal(req.body.items);
if (!pricing.success) {
return new Response(pricing.error, { status: 404 });
}
const order = await saveOrder(req.body.items, pricing);
return Response.json(order, { status: 201 });
}
La fonction principale raconte une histoire : valider, calculer, sauvegarder. Chaque détail est délégué.
La regle des 3 paramètres
Au-dela de 3 paramètres, une fonction devient difficile a appeler correctement. L'ordre des arguments n'est plus évident. Les erreurs d'inversion se multiplient.
typescript// 6 parametres - impossible de se souvenir de l'ordre
function createUser(
name: string,
email: string,
age: number,
role: string,
department: string,
isActive: boolean
) { ... }
// Appel - lequel est lequel ?
createUser("Alice", "alice@mail.com", 30, "admin", "engineering", true);
La solution : un objet de configuration.
typescripttype CreateUserInput = {
name: string;
email: string;
age: number;
role: string;
department: string;
isActive: boolean;
};
function createUser(input: CreateUserInput) { ... }
// Appel - chaque valeur est nommee
createUser({
name: "Alice",
email: "alice@mail.com",
age: 30,
role: "admin",
department: "engineering",
isActive: true,
});
Chaque argument est nomme a l'appel. Impossible de confondre l'age et le department. L'ordre n'a plus d'importance. Et tu peux ajouter des champs optionnels sans casser les appels existants.
Quand garder des paramètres individuels ? Quand il y en a 1 a 3 et que leur rôle est évident :
typescriptfunction add(a: number, b: number): number { ... }
function greet(name: string): string { ... }
function fetchUser(id: string): Promise<User> { ... }
Early returns : aplatir les conditions
Les if imbriques creent la "pyramide du malheur". Chaque niveau d'imbrication augmente la charge cognitive. Compare :
typescript// Pyramide du malheur
function processPayment(order: Order, user: User) {
if (order) {
if (order.items.length > 0) {
if (user) {
if (user.isActive) {
if (user.hasPaymentMethod) {
// enfin, la logique utile
return chargeUser(user, order.total);
} else {
throw new Error("No payment method");
}
} else {
throw new Error("User inactive");
}
} else {
throw new Error("No user");
}
} else {
throw new Error("Empty order");
}
} else {
throw new Error("No order");
}
}
Avec des early returns :
typescriptfunction processPayment(order: Order, user: User) {
if (!order) throw new Error("No order");
if (order.items.length === 0) throw new Error("Empty order");
if (!user) throw new Error("No user");
if (!user.isActive) throw new Error("User inactive");
if (!user.hasPaymentMethod) throw new Error("No payment method");
return chargeUser(user, order.total);
}
Meme logique. Zero imbrication. Le "happy path" est a la fin, bien visible. Les cas d'erreur sont geres en haut et evacuees immédiatement. On reparle de ce pattern dans l'article sur les conditions.
Effets de bord : les identifier et les isoler
Un effet de bord, c'est quand une fonction modifie quelque chose en dehors de son scope : écrire en base, envoyer un email, modifier une variable globale, muter un paramètre.
typescript// Cette fonction a un effet de bord cache
function calculateDiscount(order: Order): number {
const discount = order.total > 100 ? 0.1 : 0;
order.appliedDiscount = discount; // mutation du parametre !
analytics.track("discount_calculated"); // appel externe !
return discount;
}
Le problème : l'appelant s'attend a un calcul pur. Il obtient une mutation et un appel réseau en bonus. Separe les deux :
typescript// Fonction pure - aucun effet de bord
function calculateDiscount(total: number): number {
return total > 100 ? 0.1 : 0;
}
// Effets de bord explicites
function applyDiscount(order: Order): Order {
const discount = calculateDiscount(order.total);
return { ...order, appliedDiscount: discount };
}
function trackDiscountEvent(discount: number): void {
analytics.track("discount_calculated", { discount });
}
Les fonctions pures sont faciles a tester, faciles a raisonner, faciles a composer. On creuse le sujet dans l'article sur l'immutabilité.
Command-Query Separation (CQS)
Le principe est simple : une fonction soit fait quelque chose (command), soit retourne quelque chose (query). Pas les deux.
typescript// Mauvais : cette fonction modifie ET retourne
function addItemAndGetTotal(cart: Cart, item: Item): number {
cart.items.push(item); // command
return cart.items.reduce((sum, i) => sum + i.price, 0); // query
}
// Bon : deux fonctions separees
function addItem(cart: Cart, item: Item): Cart {
return { ...cart, items: [...cart.items, item] };
}
function getTotal(cart: Cart): number {
return cart.items.reduce((sum, item) => sum + item.price, 0);
}
Comme tout principe, il y a des exceptions. Un array.pop() qui retire un élément et le retourne viole CQS, et c'est acceptable. L'idee est d'y tendre, pas de s'y conformer a 100%. J'en parle dans un article sur la gestion de projets concrets sur paltemps.fr.
Composition de fonctions
Des petites fonctions se composent naturellement :
typescriptfunction normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
function isValidEmailFormat(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
function isDisposableEmail(email: string): boolean {
const disposableDomains = ["tempmail.com", "throwaway.io"];
const domain = email.split("@")[1];
return disposableDomains.includes(domain);
}
// Composition
function validateEmail(rawEmail: string): {
isValid: boolean;
email: string;
reason?: string;
} {
const email = normalizeEmail(rawEmail);
if (!isValidEmailFormat(email)) {
return { isValid: false, email, reason: "Format invalide" };
}
if (isDisposableEmail(email)) {
return { isValid: false, email, reason: "Email jetable interdit" };
}
return { isValid: true, email };
}
Chaque fonction fait une seule chose. Chaque fonction est testable indépendamment. La fonction de composition orchestre le tout.
Résumé
- Vise moins de 30 lignes par fonction, mais la responsabilité unique prime sur le nombre de lignes
- Au-dela de 3 paramètres, utilise un objet
- Les early returns aplatissent les conditions et rendent le happy path visible
- Isole les effets de bord des fonctions pures
- Prefere CQS : une fonction modifie OU retourne, pas les deux
- Des petites fonctions se composent en fonctions plus complexes
Article précédent : 01 - Nommage
Article suivant : 03 - Conditions et lisibilité
Sources
- Robert C. Martin, "Clean Code", Chapter 3: Functions - https://www.oreilly.com/library/view/clean-code-a/9780136083238/
- Martin Fowler, "Refactoring: Improving the Design of Existing Code", 2nd Édition - https://martinfowler.com/books/refactoring.html
- Bertrand Meyer, "Command-Query Separation" - https://en.wikipedia.org/wiki/Command%E2%80%93query_separation