11 - Complexite cyclomatique -- mesurer, réduire, testabilité
Ce que tu vas apprendre
- Ce que la complexité cyclomatique mesure concrètement
- Comment la calculer et quels outils l'automatisent
- Le lien direct entre complexité et difficulte de test
- Des techniques pour réduire la complexité sans perdre de fonctionnalité
- La différence entre complexité cyclomatique et complexité cognitive
Prerequisites
Cet article fait suite a 10 - Couplage et cohesion. Les notions de SOLID et de KISS seront utiles.
Un jour, j'ai ouvert une fonction de 200 lignes avec 14 niveaux d'imbrication. Elle gerait les permissions d'acces. Chaque if avait un else qui avait un if qui avait un switch. J'ai essaye d'écrire un test. Au bout de 45 minutes, j'avais couvert 3 des 47 chemins possibles. La complexité cyclomatique de cette fonction etait de 47. Autrement dit : 47 tests minimum pour une couverture complète des branches.
Qu'est-ce que la complexité cyclomatique ?
Thomas McCabe a introduit cette metrique en 1976. L'idee est simple : on compte le nombre de chemins lineairement independants dans une fonction. Chaque point de décision (if, else, while, for, case, &&, ||, ternaire) ajoute un chemin.
typescript// Complexite = 1 (aucune branche)
function greet(name: string): string {
return `Bonjour ${name}`;
}
// Complexite = 3 (deux if = deux branches supplementaires)
function getDiscount(user: User): number {
let discount = 0; // chemin de base = 1
if (user.isPremium) { // +1
discount = 10;
}
if (user.orderCount > 50) { // +1
discount += 5;
}
return discount; // total = 3
}
La formule : M = E - N + 2P, ou E est le nombre d'aretes du graphe de contrôle, N le nombre de noeuds, et P le nombre de composantes connexes. En pratique, on compte les points de décision et on ajoute 1.
Pourquoi ca compte pour les tests
Une fonction de complexité N a au minimum N chemins a tester pour une couverture de branche complète. En pratique, les combinaisons explosent.
typescript// Complexite = 7 -- un monstre courant
function processOrder(order: Order, user: User): Result {
if (!order.items.length) { // +1
return { status: "empty" };
}
if (!user.isActive) { // +1
return { status: "inactive_user" };
}
let total = 0;
for (const item of order.items) { // +1
if (item.quantity <= 0) { // +1
return { status: "invalid_quantity" };
}
total += item.price * item.quantity;
}
if (total > 10000 && !user.isPremium) { // +1, +1 (&&)
return { status: "limit_exceeded" };
}
return { status: "ok", total };
}
Sept chemins. Sept cas de test minimum. Et c'est une fonction relativement simple. Imagine ce que donne une fonction avec des try/catch imbriques, des switch de 15 cases, et des conditions combinees.
Comment mesurer
Plusieurs outils calculent la complexité cyclomatique automatiquement.
En ESLint, la regle complexity :
json{
"rules": {
"complexity": ["error", { "max": 10 }]
}
}
SonarQube la mesure aussi, avec des seuils configurables. La plupart des équipes mettent le seuil a 10 ou 15. Au-dela de 10, la fonction merite probablement d'etre decoupee.
Les seuils courants :
| Complexite | Interprétation |
|---|---|
| 1-5 | Simple, facile a tester |
| 6-10 | Acceptable, attention |
| 11-20 | Risque, difficile a maintenir |
| 21+ | Danger, refactoring urgent |
Techniques de réduction
Early returns
La technique la plus simple et la plus efficace. Au lieu d'imbriquer, on sort tot.
typescript// Avant : imbrication profonde (complexite = 5)
function processPayment(payment: Payment): Result {
if (payment) {
if (payment.amount > 0) {
if (payment.method) {
if (isValidMethod(payment.method)) {
return charge(payment);
} else {
return { error: "invalid_method" };
}
} else {
return { error: "no_method" };
}
} else {
return { error: "invalid_amount" };
}
} else {
return { error: "no_payment" };
}
}
// Apres : early returns (meme complexite, mais lisibilite ++)
function processPayment(payment: Payment): Result {
if (!payment) return { error: "no_payment" };
if (payment.amount <= 0) return { error: "invalid_amount" };
if (!payment.method) return { error: "no_method" };
if (!isValidMethod(payment.method)) return { error: "invalid_method" };
return charge(payment);
}
La complexité cyclomatique est la meme (le nombre de branches n'a pas change). Mais la complexité cognitive -- celle que ton cerveau ressent -- est bien plus basse.
Extraire des fonctions
Chaque extraction réduit la complexité de la fonction d'origine.
typescript// Avant : tout dans une fonction (complexite = 8)
function createInvoice(order: Order): Invoice {
// validation (3 branches)
// calcul des taxes (2 branches)
// formatage (3 branches)
}
// Apres : trois fonctions de complexite 3, 2, et 3
function createInvoice(order: Order): Invoice {
validateOrder(order);
const taxes = calculateTaxes(order);
return formatInvoice(order, taxes);
}
Remplacer les conditions par du polymorphisme
Quand un switch ou une chaîne de if/else fait la meme chose a chaque ajout, le pattern Strategy le remplace.
typescript// Avant : switch qui grandit a chaque nouveau type (complexite = N+1)
function calculateShipping(method: string, weight: number): number {
switch (method) {
case "standard": return weight * 0.5;
case "express": return weight * 1.2 + 5;
case "overnight": return weight * 2.0 + 15;
case "drone": return weight * 3.0 + 25;
default: throw new Error(`Unknown method: ${method}`);
}
}
// Apres : une map de strategies (complexite = 1)
const shippingStrategies: Record<string, (weight: number) => number> = {
standard: (w) => w * 0.5,
express: (w) => w * 1.2 + 5,
overnight: (w) => w * 2.0 + 15,
drone: (w) => w * 3.0 + 25,
};
function calculateShipping(method: string, weight: number): number {
const strategy = shippingStrategies[method];
if (!strategy) throw new Error(`Unknown method: ${method}`);
return strategy(weight);
}
La complexité de calculateShipping est passee de 5 a 2. Chaque stratégie a une complexité de 1. Et ajouter un nouveau mode de livraison ne touche pas la fonction.
Complexite cognitive vs cyclomatique
SonarSource a introduit la complexité cognitive en 2017. Elle tente de mesurer ce que le cerveau humain ressent, pas juste le nombre de chemins.
typescript// Complexite cyclomatique = 4, complexite cognitive = 1
function getLabel(status: Status): string {
switch (status) {
case "active": return "Actif";
case "pending": return "En attente";
case "disabled": return "Desactive";
default: return "Inconnu";
}
}
// Complexite cyclomatique = 3, complexite cognitive = 7
function process(a: boolean, b: boolean, c: boolean): string {
if (a) { // +1
if (b) { // +2 (imbrication)
if (c) { // +3 (imbrication double)
return "abc";
}
}
return "a"; // +1
}
return "none";
}
Le switch est simple a lire malgre sa complexité cyclomatique de 4. Les if imbriques sont penibles malgre une complexité cyclomatique plus basse. La complexité cognitive capture mieux cette réalité.
Les deux metriques sont utiles. La cyclomatique aide pour le nombre de tests. La cognitive aide pour la lisibilité. Plus de détails sur les metriques de qualité de code sur paltemps.fr.
Résumé
- La complexité cyclomatique compte les chemins independants dans une fonction
- Chaque
if,for,while,case,&&,||ajoute un chemin - Une complexité supérieure a 10 est un signal de refactoring
- Les early returns reduisent la complexité cognitive sans changer la cyclomatique
- Extraire des fonctions et utiliser le polymorphisme reduisent la complexité réelle
- La complexité cognitive mesure l'effort mental, pas juste les chemins
Article précédent : 10 - Couplage et cohesion
Article suivant : 12 - Abstractions prematurees vs tardives