03 — Comparaison directe : le même problème, deux solutions
Ce que tu vas apprendre
- Le même problème résolu en POO et en fonctionnel côte à côte
- Gestion d'état, gestion d'erreurs, testabilité, concurrence
- Quand chaque approche est plus adaptée
- Ce que les benchmarks et études disent sur la maintenabilité
Prérequis
Problème 1 : traiter une liste de commandes
Contexte : calculer le revenu total des commandes livrées d'un mois donné, avec une remise de 10% sur les commandes de plus de 500€.
Solution POO
typescriptclass OrderProcessor {
private orders: Order[];
constructor(orders: Order[]) {
this.orders = orders;
}
getMonthlyRevenue(year: number, month: number): number {
let total = 0;
for (const order of this.orders) {
if (order.status !== "delivered") continue;
if (order.date.getFullYear() !== year) continue;
if (order.date.getMonth() + 1 !== month) continue;
const amount = order.total > 500
? order.total * 0.9
: order.total;
total += amount;
}
return total;
}
}
const processor = new OrderProcessor(orders);
const revenue = processor.getMonthlyRevenue(2026, 6);
Solution fonctionnelle
typescript// Fonctions pures — chacune fait une seule chose
const isDelivered = (order: Order): boolean =>
order.status === "delivered";
const isFromMonth = (year: number, month: number) => (order: Order): boolean =>
order.date.getFullYear() === year && order.date.getMonth() + 1 === month;
const applyBulkDiscount = (order: Order): number =>
order.total > 500 ? order.total * 0.9 : order.total;
const sum = (numbers: number[]): number =>
numbers.reduce((acc, n) => acc + n, 0);
// Composition — lisible comme une phrase
const getMonthlyRevenue = (orders: Order[], year: number, month: number): number =>
sum(
orders
.filter(isDelivered)
.filter(isFromMonth(year, month))
.map(applyBulkDiscount)
);
const revenue = getMonthlyRevenue(orders, 2026, 6);
En Python :
pythonfrom typing import List
from functools import reduce
# POO
class OrderProcessor:
def __init__(self, orders: List[dict]) -> None:
self._orders = orders
def get_monthly_revenue(self, year: int, month: int) -> float:
total = 0.0
for order in self._orders:
if order["status"] != "delivered":
continue
if order["date"].year != year or order["date"].month != month:
continue
amount = order["total"] * 0.9 if order["total"] > 500 else order["total"]
total += amount
return total
# Fonctionnel
def is_delivered(order: dict) -> bool:
return order["status"] == "delivered"
def is_from_month(year: int, month: int):
return lambda order: order["date"].year == year and order["date"].month == month
def apply_bulk_discount(order: dict) -> float:
return order["total"] * 0.9 if order["total"] > 500 else order["total"]
def get_monthly_revenue(orders: List[dict], year: int, month: int) -> float:
filtered = filter(is_delivered, orders)
filtered = filter(is_from_month(year, month), filtered)
amounts = map(apply_bulk_discount, filtered)
return sum(amounts)
La version fonctionnelle est plus facile à tester : chaque fonction peut être testée isolément sans instancier un objet.
isDelivered,applyBulkDiscountetsumsont des fonctions pures — un input, un output.
Problème 2 : gestion d'erreurs
Contexte : lire un fichier de config JSON, valider les champs obligatoires, retourner une config ou une erreur.
Solution POO (exceptions)
typescriptclass ConfigLoader {
load(path: string): Config {
let raw: string;
try {
raw = fs.readFileSync(path, "utf-8");
} catch (e) {
throw new Error(`Fichier introuvable : ${path}`);
}
let data: unknown;
try {
data = JSON.parse(raw);
} catch (e) {
throw new Error(`JSON invalide dans ${path}`);
}
if (!data || typeof data !== "object") {
throw new Error("Config vide");
}
const obj = data as Record<string, unknown>;
if (typeof obj.apiUrl !== "string" || !obj.apiUrl) {
throw new Error("apiUrl manquant ou invalide");
}
if (typeof obj.timeout !== "number" || obj.timeout <= 0) {
throw new Error("timeout manquant ou invalide");
}
return { apiUrl: obj.apiUrl, timeout: obj.timeout };
}
}
// Usage
try {
const config = new ConfigLoader().load("./config.json");
startApp(config);
} catch (e) {
console.error("Erreur de config :", e.message);
process.exit(1);
}
Solution fonctionnelle (Result type)
typescripttype Result<T, E = string> =
| { ok: true; value: T }
| { ok: false; error: E };
function readFile(path: string): Result<string> {
try {
return { ok: true, value: fs.readFileSync(path, "utf-8") };
} catch {
return { ok: false, error: `Fichier introuvable : ${path}` };
}
}
function parseJson(raw: string): Result<unknown> {
try {
return { ok: true, value: JSON.parse(raw) };
} catch {
return { ok: false, error: "JSON invalide" };
}
}
function validateConfig(data: unknown): Result<Config> {
if (!data || typeof data !== "object") {
return { ok: false, error: "Config vide" };
}
const obj = data as Record<string, unknown>;
if (typeof obj.apiUrl !== "string" || !obj.apiUrl) {
return { ok: false, error: "apiUrl manquant ou invalide" };
}
if (typeof obj.timeout !== "number" || obj.timeout <= 0) {
return { ok: false, error: "timeout manquant ou invalide" };
}
return { ok: true, value: { apiUrl: obj.apiUrl, timeout: obj.timeout } };
}
// Chaînage explicite — pas de try/catch imbriqués
function loadConfig(path: string): Result<Config> {
const file = readFile(path);
if (!file.ok) return file;
const json = parseJson(file.value);
if (!json.ok) return json;
return validateConfig(json.value);
}
// Usage
const result = loadConfig("./config.json");
if (!result.ok) {
console.error("Erreur de config :", result.error);
process.exit(1);
}
startApp(result.value);
La version fonctionnelle rend le chemin d'erreur explicite dans le type. Il est impossible d'oublier de gérer une erreur — le compilateur TypeScript le détecte.
Problème 3 : gestion d'état partagé
Contexte : un compteur de visites partagé entre plusieurs handlers HTTP.
Solution POO (état mutable partagé)
typescriptclass VisitCounter {
private count: number = 0;
increment(): void {
this.count++;
}
getCount(): number {
return this.count;
}
}
const counter = new VisitCounter();
// Problème en concurrence :
// Requête A lit count = 5
// Requête B lit count = 5
// Requête A écrit count = 6
// Requête B écrit count = 6 ← perd l'incrémentation de A
Solution fonctionnelle (état explicite et immutable)
typescript// L'état est passé explicitement — pas de surprise
type State = { visitCount: number };
function incrementVisit(state: State): State {
return { ...state, visitCount: state.visitCount + 1 };
}
// Avec un gestionnaire d'état centralisé (Redux-like)
let appState: State = { visitCount: 0 };
function dispatch(action: (s: State) => State): State {
appState = action(appState); // Mise à jour centralisée
return appState;
}
// Usage
dispatch(incrementVisit);
dispatch(incrementVisit);
console.log(appState.visitCount); // 2
En environnement concurrent (Node.js, Python asyncio), la version fonctionnelle avec état immutable évite les race conditions — si l'état ne mute pas, il n'y a rien à synchroniser.
Comparaison sur 5 dimensions
Les 5 dimensions
Testabilité : le fonctionnel gagne sur les transformations de données (fonctions pures, pas de mock). La POO gagne sur les domaines complexes avec cycle de vie.
Lisibilité : la POO est plus intuitive pour les développeurs débutants. Le fonctionnel est plus lisible pour des pipelines de transformation.
Concurrence : le fonctionnel gagne — l'immutabilité élimine les race conditions.
Modélisation du domaine : la POO gagne — les objets avec état et comportement modélisent mieux les entités métier.
Maintenabilité : les deux. Les études mesurent des résultats mitigés selon le contexte.
Ce que dit la recherche
Raymond Hettinger (core developer Python) a montré que les comprehensions et les générateurs Python, inspirés du FP, produisent du code plus lisible et moins sujet aux bugs d'index que les boucles impératives.
Une étude de Nanz & Furia (2015) sur la productivité des paradigmes (A Comparative Study of Programming Languages in Rosetta Code) montre que les langages fonctionnels produisent en moyenne des solutions plus courtes — mais la corrélation avec la maintenabilité à long terme dépend du contexte.
Robert C. Martin, dans Clean Architecture (2017), note que le paradigme fonctionnel contraint l'assignation (pas de mutation), ce qui le rend naturellement adapté aux systèmes distribués et concurrents.
Tableau de décision
| Situation | Approche recommandée | Pourquoi |
|---|---|---|
| Transformation de données (ETL, parsing) | Fonctionnel | Pipelines clairs, testabilité maximale |
| Domaine avec entités et cycle de vie | POO | Encapsulation des invariants |
| Traitement parallèle / concurrent | Fonctionnel | Immutabilité = pas de race condition |
| UI avec état complexe | Hybride (Redux/FP pour état, composants OOP) | Clarté + réactivité |
| APIs REST simples | Fonctionnel ou hybrid | Handlers purs = tests simples |
| Simulation de systèmes physiques | POO | Entités avec état — le modèle mental colle |
Résumé
Aucun paradigme n'est supérieur à l'autre sur tous les cas. La vraie compétence est de savoir les distinguer :
- Transformations de données → fonctionnel
- Entités à cycle de vie → POO
- Systèmes concurrents → fonctionnel (immutabilité)
- Cas mixtes → hybride (TypeScript et Python le permettent nativement)
Étape suivante : 04 — L'approche hybride en TypeScript et Python
Sources
- Martin, R. C. (2017). Clean Architecture: A Craftsman's Guide to Software Structure and Design. Prentice Hall.
- Nanz, S., & Furia, C. A. (2015). A Comparative Study of Programming Languages in Rosetta Code. Proceedings of ICSE 2015.
- Wlaschin, S. (2018). Domain Modeling Made Functional. Pragmatic Bookshelf.
- Meijer, E. (2012). Your Mouse is a Database. ACM Queue, 10(3).
- Haskell Wiki. (2023). Why Haskell matters. haskell.org/wiki.