Modern Python Engineering
Chapitre 1
01-Fondamentaux-Python
01-Fondamentaux-Python
Fondamentaux Python — Cours Complet
1. Types Fondamentaux
Types numériques
from decimal import Decimal
from fractions import Fraction
# int — précision arbitraire
a: int = 42
b: int = 2**1000 # très grand entier
# float — IEEE 754 double précision
c: float = 3.14159
d: float = 1.5e-10
# Decimal — précision exacte (finances)
e: Decimal = Decimal("0.1") + Decimal("0.2") # exact: 0.3
f: float = 0.1 + 0.2 # 0.30000000000000004
# Fraction — rationnels exacts
g: Fraction = Fraction(1, 3) + Fraction(1, 6) # 1/2
Chaînes de caractères
# str — Unicode immuable
s1: str = "hello"
s2: str = "world"
s3: str = f"{s1} {s2}" # f-string (PEP 498)
s4: str = f"{s1.upper()} {s2!r}"
s5: str = """multi
ligne"""
# Méthodes essentielles
s1.upper(), s1.lower(), s1.title()
s1.strip(), s1.split(), " ".join(["a", "b"])
s1.startswith("h"), s1.endswith("o")
s1.replace("h", "H"), s1.find("e")
# bytes — données binaires
b: bytes = b"hello"
ba: bytearray = bytearray(b"hello")
ba[0] = 72 # mutable
# str → bytes
encoded: bytes = "héllo".encode("utf-8") # b'h\xc3\xa9llo'
# bytes → str
decoded: str = encoded.decode("utf-8") # 'héllo'
Listes
# list — séquence mutable ordonnée
lst: list[int] = [1, 2, 3]
lst.append(4) # [1, 2, 3, 4]
lst.extend([5, 6]) # [1, 2, 3, 4, 5, 6]
lst.insert(0, 0) # [0, 1, 2, 3, 4, 5, 6]
lst.pop() # 6
lst.remove(0) # [1, 2, 3, 4, 5]
lst.sort(reverse=True) # [5, 4, 3, 2, 1]
# Slicing
lst = [0, 1, 2, 3, 4, 5]
lst[1:3] # [1, 2]
lst[:3] # [0, 1, 2]
lst[3:] # [3, 4, 5]
lst[::2] # [0, 2, 4]
lst[::-1] # [5, 4, 3, 2, 1, 0]
# List as stack — O(1)
stack: list[int] = []
stack.append(1)
stack.append(2)
top = stack.pop() # 2
# List as queue — O(n) avec pop(0), préférer collections.deque
from collections import deque
queue: deque[int] = deque()
queue.append(1)
queue.append(2)
first = queue.popleft() # 1 — O(1)
Dictionnaires
# dict — table de hachage (3.7+ : insertion order preserved)
d: dict[str, int] = {"a": 1, "b": 2}
d["c"] = 3 # ajout
d.get("d", 0) # 0 (default)
d.setdefault("e", 5) # 5 si absent
d.update({"f": 6, "g": 7})
# 3.9+ : merge operators
d1 = {"a": 1, "b": 2}
d2 = {"b": 3, "c": 4}
merged = d1 | d2 # {"a": 1, "b": 3, "c": 4}
d1 |= d2 # in-place merge
# Views
for key in d: ... # keys (default)
for key, value in d.items(): ...
for value in d.values(): ...
# defaultdict — valeur par défaut automatique
from collections import defaultdict
dd = defaultdict(list)
dd["a"].append(1) # pas de KeyError
# Counter — compteur
from collections import Counter
c = Counter("hello world")
c.most_common(3) # [('l', 3), ('o', 2), (' ', 1)]
Ensembles
# set — collection non-ordonnée, hachable, unique
s: set[int] = {1, 2, 3, 1} # {1, 2, 3}
s.add(4)
s.remove(2) # KeyError si absent
s.discard(5) # safe
# Opérations ensemblistes
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
a | b # union -> {1, 2, 3, 4, 5, 6}
a & b # intersect -> {3, 4}
a - b # diff -> {1, 2}
a ^ b # sym diff -> {1, 2, 5, 6}
# frozenset — immuable, hachable (clé de dict)
fs: frozenset[int] = frozenset([1, 2, 3])
Tuples
# tuple — séquence immuable, hachable
t: tuple[int, str, float] = (1, "a", 3.14)
# Named tuple
from collections import namedtuple
Point = namedtuple("Point", ["x", "y"])
p = Point(3, 4)
p.x, p.y, p[0], p[1] # accès par nom et index
# Typed named tuple (3.12+)
from typing import NamedTuple
class Employee(NamedTuple):
name: str
id: int
e = Employee("Alice", 123)
name, id = e # unpacking
2. Fonctions
Paramètres avancés
def func(
a: int, # positional
b: str = "default", # default
*args: int, # *args — tuple
c: int, # keyword-only (après *)
d: str = "kw-default", # keyword-only avec default
**kwargs: str, # **kwargs — dict
) -> None:
...
# Appels
func(1, "hello", 2, 3, c=4, d="world", extra="x")
# Positional-only (3.8+)
def divide(a: int, b: int, /) -> float:
"""a et b sont positional-only."""
return a / b
divide(10, 3) # OK
# divide(a=10, b=3) # TypeError!
Lambda
# Lambda — fonction anonyme à une expression
square = lambda x: x ** 2
add = lambda a, b: a + b
# Usage typique : sorting
pairs = [(1, "one"), (3, "three"), (2, "two")]
pairs.sort(key=lambda x: x[0])
# map/filter
list(map(lambda x: x * 2, [1, 2, 3])) # [2, 4, 6]
list(filter(lambda x: x > 0, [-1, 0, 1])) # [1]
Closures
def make_counter() -> callable:
"""Une closure — une fonction avec un état capturé."""
count = 0
def counter() -> int:
nonlocal count
count += 1
return count
return counter
c1 = make_counter()
c1() # 1
c1() # 2
c2 = make_counter()
c2() # 1 (indépendant)
functools
import functools
# partial — fixe des arguments
def power(base: float, exp: float) -> float:
return base ** exp
square = functools.partial(power, exp=2)
cube = functools.partial(power, exp=3)
square(5) # 25
cube(5) # 125
# reduce — accumulation
from functools import reduce
product = reduce(lambda a, b: a * b, [1, 2, 3, 4]) # 24
# singledispatch — polymorphism par type
from functools import singledispatch
@singledispatch
def process(obj):
raise NotImplementedError(f"Type {type(obj)} not supported")
@process.register(int)
def process_int(obj: int) -> str:
return f"Integer: {obj}"
@process.register(str)
def process_str(obj: str) -> str:
return f"String: {obj}"
@process.register(list)
def process_list(obj: list) -> str:
return f"List with {len(obj)} items"
process(42) # "Integer: 42"
process("hi") # "String: hi"
process([1]) # "List with 1 items"
3. Décorateurs
Principe
Un décorateur est une fonction qui prend une fonction et retourne une fonction modifiée.
Décorateur simple
from collections.abc import Callable
import functools
def timer[**P, T](func: Callable[P, T]) -> Callable[P, T]:
"""Mesure le temps d'exécution."""
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
import time
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print(f"{func.__name__} took {elapsed:.4f}s")
return result
return wrapper
@timer
def compute(n: int) -> int:
return sum(range(n))
Decorator avec paramètres
def retry[**P, T](max_attempts: int = 3, delay: float = 0.1) -> Callable[[Callable[P, T]], Callable[P, T]]:
"""Réessaie une fonction qui échoue."""
def decorator(func: Callable[P, T]) -> Callable[P, T]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
import time
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except Exception as e:
if attempt == max_attempts - 1:
raise
time.sleep(delay)
raise RuntimeError("Unreachable") # never reached
return wrapper
return decorator
@retry(max_attempts=5, delay=0.5)
def unstable_api_call() -> str:
import random
if random.random() < 0.7:
raise ConnectionError("Network error")
return "Success"
@lru_cache et @cache
from functools import lru_cache, cache
@lru_cache(maxsize=128)
def fibonacci(n: int) -> int:
"""Fibonacci avec mémoïsation."""
if n < 2:
return n
return fibonacci(n - 1) + fibonacci(n - 2)
@cache # 3.9+ — équivalent à lru_cache(maxsize=None)
def expensive_computation(x: float, y: float) -> float:
import time
time.sleep(1)
return x ** y + y ** x
Decorator en classe
from collections.abc import Callable
import functools
class CountCalls:
"""Compte le nombre d'appels à une fonction."""
def __init__(self, func: Callable) -> None:
functools.update_wrapper(self, func)
self.func = func
self.count = 0
def __call__(self, *args, **kwargs):
self.count += 1
return self.func(*args, **kwargs)
@CountCalls
def hello(name: str) -> str:
return f"Hello, {name}!"
hello("Alice") # "Hello, Alice!"
hello("Bob") # "Hello, Bob!"
print(hello.count) # 2
4. Générateurs
yield
def fibonacci(limit: int) -> Generator[int, None, None]:
"""Générateur de la suite de Fibonacci."""
a, b = 0, 1
while a < limit:
yield a
a, b = b, a + b
for num in fibonacci(100):
print(num) # 0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89
Generator Expressions
# Generator expression — lazy, mémoire efficace
squares = (x ** 2 for x in range(10_000_000))
first_5 = [next(squares) for _ in range(5)] # [0, 1, 4, 9, 16]
# vs list comprehension — eager, mémoire
squares_list = [x ** 2 for x in range(10_000_000)] # ~300MB !
yield from
def chain(*iterables: Iterable[T]) -> Generator[T, None, None]:
"""Chaîne plusieurs itérables en séquence."""
for iterable in iterables:
yield from iterable # délègue à un sous-générateur
# Équivalent sans yield from :
def chain_verbose(*iterables: Iterable[T]) -> Generator[T, None, None]:
for iterable in iterables:
for item in iterable:
yield item
list(chain([1, 2], "ab", (3, 4))) # [1, 2, 'a', 'b', 3, 4]
send — Bidirectional Generators
def accumulator() -> Generator[int, int, str]:
"""Accumule des valeurs et retourne la somme finale."""
total = 0
while True:
value = yield total # reçoit une valeur via send()
if value is None:
break
total += value
return f"Final total: {total}"
gen = accumulator()
next(gen) # démarre le générateur, retourne 0
gen.send(10) # 10
gen.send(20) # 30
gen.send(30) # 60
try:
gen.send(None) # StopIteration avec message
except StopIteration as e:
print(e.value) # "Final total: 60"
5. Itérateurs
Le protocole d'itération
class Range:
"""Itérateur personnalisé simulant range()."""
def __init__(self, start: int, stop: int, step: int = 1) -> None:
self.current = start
self.stop = stop
self.step = step
def __iter__(self):
return self # l'itérateur est son propre itérateur
def __next__(self) -> int:
if self.current >= self.stop:
raise StopIteration
value = self.current
self.current += self.step
return value
# Usage
for i in Range(0, 5):
print(i) # 0, 1, 2, 3, 4
# Itérable + itérateur séparés
class MyIterable:
"""Un itérable qui crée un itérateur à chaque itération."""
def __init__(self, data: list[int]):
self.data = data
def __iter__(self):
return MyIterator(self.data)
class MyIterator:
def __init__(self, data: list[int]):
self.data = data
self.index = 0
def __iter__(self):
return self
def __next__(self) -> int:
if self.index >= len(self.data):
raise StopIteration
value = self.data[self.index]
self.index += 1
return value
itertools
import itertools
# Compteurs infinis
counter = itertools.count(start=0, step=2)
next(counter) # 0, 2, 4, 6, ...
cycle = itertools.cycle("ABC")
next(cycle) # A, B, C, A, B, ...
# Combinations et permutations
list(itertools.permutations("ABC", 2))
# [('A','B'), ('A','C'), ('B','A'), ('B','C'), ('C','A'), ('C','B')]
list(itertools.combinations("ABC", 2))
# [('A','B'), ('A','C'), ('B','C')]
list(itertools.product("AB", "12"))
# [('A','1'), ('A','2'), ('B','1'), ('B','2')]
# Groupement
data = [("a", 1), ("a", 2), ("b", 3)]
for key, group in itertools.groupby(data, key=lambda x: x[0]):
print(key, list(group))
# Chaînage et compression
list(itertools.chain([1, 2], [3, 4])) # [1, 2, 3, 4]
list(itertools.compress("ABCD", [1, 0, 1, 0])) # ['A', 'C']
# islice — slicing lazy
list(itertools.islice(range(100), 5)) # [0, 1, 2, 3, 4]
# takewhile / dropwhile
list(itertools.takewhile(lambda x: x < 5, [1, 3, 7, 2, 9])) # [1, 3]
list(itertools.dropwhile(lambda x: x < 5, [1, 3, 7, 2, 9])) # [7, 2, 9]
# zip_longest
list(itertools.zip_longest("AB", "123", fillvalue="?"))
# [('A', '1'), ('B', '2'), ('?', '3')]
# accumulate — running total
list(itertools.accumulate([1, 2, 3, 4])) # [1, 3, 6, 10]
6. Context Managers
with statement
# Gestion de ressources
with open("file.txt", "w") as f:
f.write("hello")
# Multiples contextes
with open("a.txt") as f1, open("b.txt") as f2:
for line1, line2 in zip(f1, f2):
print(line1, line2)
# 3.10+ : parenthèses pour multi-lignes
with (
open("a.txt") as f1,
open("b.txt") as f2,
):
pass
Implémentation manuelle
class ManagedFile:
"""Context manager pour fichier."""
def __init__(self, filename: str, mode: str = "r") -> None:
self.filename = filename
self.mode = mode
self.file = None
def __enter__(self):
self.file = open(self.filename, self.mode)
return self.file
def __exit__(self, exc_type, exc_val, exc_tb):
if self.file:
self.file.close()
# Ne pas supprimer l'exception si elle existe
return False
@contextmanager
from contextlib import contextmanager
@contextmanager
def managed_file(filename: str, mode: str = "r"):
"""Context manager via générateur."""
file = open(filename, mode)
try:
yield file # point de suspension
finally:
file.close()
# Usage
with managed_file("test.txt", "w") as f:
f.write("hello")
ExitStack
from contextlib import ExitStack
def process_files(filenames: list[str]) -> None:
"""Ouvre un nombre variable de fichiers."""
with ExitStack() as stack:
files = [
stack.enter_context(open(fname))
for fname in filenames
]
# Tous les fichiers sont fermés automatiquement
for f in files:
print(f.read())
# Gestion conditionnelle
def maybe_open(should_open: bool) -> None:
with ExitStack() as stack:
if should_open:
f = stack.enter_context(open("file.txt"))
# f est fermé seulement si ouvert
Context managers courants
from contextlib import redirect_stdout, redirect_stderr, suppress, nullcontext
import io
# Rediriger stdout
buf = io.StringIO()
with redirect_stdout(buf):
print("hidden output")
buf.getvalue() # "hidden output\n"
# Supprimer une exception
with suppress(FileNotFoundError):
open("nonexistent.txt")
# nullcontext — utile comme placeholder
from contextlib import nullcontext
ctx = nullcontext() if some_condition else managed_file("test.txt")
with ctx as resource:
...
7. Comprehensions
List comprehension
# Syntaxe: [expression for item in iterable if condition]
squares = [x ** 2 for x in range(10)]
# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
evens = [x for x in range(20) if x % 2 == 0]
# [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
matrix = [[1, 2], [3, 4], [5, 6]]
flattened = [num for row in matrix for num in row]
# [1, 2, 3, 4, 5, 6]
# Nested
pairs = [(x, y) for x in range(3) for y in range(3) if x != y]
Dict comprehension
# Syntaxe: {key: value for item in iterable if condition}
squares_dict = {x: x ** 2 for x in range(5)}
# {0: 0, 1: 1, 2: 4, 3: 9, 4: 16}
# Inverser un dict
original = {"a": 1, "b": 2, "c": 3}
inverted = {v: k for k, v in original.items()}
# {1: 'a', 2: 'b', 3: 'c'}
# Filtrer
filtered = {k: v for k, v in original.items() if v > 1}
Set comprehension
# Syntaxe: {expression for item in iterable if condition}
unique_lengths = {len(word) for word in ["hello", "world", "python"]}
# {5, 6}
even_squares = {x ** 2 for x in range(10) if x % 2 == 0}
# {0, 4, 16, 36, 64}
Perfomance : Comprehension vs Loop
import timeit
# List comprehension (plus rapide)
comp_time = timeit.timeit(
"[x ** 2 for x in range(1000)]", number=10000
)
# For loop (plus lent)
loop_time = timeit.timeit(
"""
squares = []
for x in range(1000):
squares.append(x ** 2)
""", number=10000
)
print(f"Comprehension: {comp_time:.3f}s")
print(f"For loop: {loop_time:.3f}s")
# Comprehension ~30-40% plus rapide
8. Tableau Récapitulatif
| Concept | Syntaxe | Usage |
|---|---|---|
| List comp | [x for x in items] | Transformer/filtrer listes |
| Dict comp | {k: v for k, v in items} | Construire dictionnaires |
| Set comp | {x for x in items} | Ensemble unique |
| Gen expr | (x for x in items) | Itérateur lazy |
| Lambda | lambda x: x + 1 | Fonction jetable |
| Decorator | @decorator | Étendre comportement |
| Generator | yield value | Itérateur stateful |
| Context | with x as y: | Gestion ressources |