iOS (Swift) Avancé

Maîtriser la Gestion Mémoire et les Performances en Swift iOS

Plongez dans les mécanismes profonds du runtime Swift pour optimiser vos applications iOS et éviter les pièges critiques de mémoire. Découvrez comment déboguer les fuites, analyser les performances et implémenter des patterns avancés de gestion des ressources.

Preparetoi.academy 30 min

1. Automatic Reference Counting (ARC) et Ownership Semantics

Définition

L'Automatic Reference Counting (ARC) est le système de gestion automatique de la mémoire en Swift qui suit le nombre de références fortes à chaque objet. Contrairement au garbage collection, ARC libère la mémoire de manière déterministe dès que le compte de références atteint zéro. Cependant, comprendre les nuances d'ARC—notamment la différence entre références fortes, faibles et non-propriétaires—est essentiel pour éviter les fuites mémoire subtiles.

Explication Détaillée

ARC fonctionne en maintenant un compteur de références pour chaque instance. Chaque assignment d'une référence à une variable incrémente ce compteur, tandis que chaque sortie de portée décrémente. Lorsque le compteur atteint zéro, l'objet est désalloué. Le piège principal est les références circulaires : quand deux objets se retiennent mutuellement avec des références fortes, aucun ne peut être libéré, créant une fuite mémoire permanente.

Les references faibles (weak) et non-propriétaires (unowned) n'incrémentent pas le compteur de références. Une reference faible devient nil quand l'objet est désalloué, tandis qu'une reference non-propriétaire provoque un crash si accédée après déallocation. Le choix entre ces trois types dépend des garanties de durée de vie que vous pouvez établir.

Bloc Code

// Cas 1: Référence circulaire - FUITE MÉMOIRE
class Parent {
    var child: Child?
    deinit { print("Parent désalloué") }
}

class Child {
    var parent: Parent?  // Référence forte - PROBLÈME!
    deinit { print("Child désalloué") }
}

var parent: Parent? = Parent()
var child: Child? = Child()
parent?.child = child
child?.parent = parent
parent = nil
child = nil
// Aucun deinit n'est appelé - fuite mémoire!

// Cas 2: Solution avec weak
class ParentFixed {
    var child: Child?
    deinit { print("Parent désalloué") }
}

class ChildFixed {
    weak var parent: ParentFixed?  // Référence faible - CORRECT
    deinit { print("Child désalloué") }
}

var parentFixed: ParentFixed? = ParentFixed()
var childFixed: ChildFixed? = ChildFixed()
parentFixed?.child = childFixed
childFixed?.parent = parentFixed
parentFixed = nil  // Parent désalloué
childFixed = nil   // Child désalloué

// Cas 3: Closures et capture lists
class ViewControllerExample {
    var networkRequest: (() -> Void)?
    
    func fetchData() {
        // PROBLÈME: capture self fortement
        networkRequest = { [weak self] in
            guard let self = self else { return }
            self.updateUI()
        }
    }
    
    func updateUI() {
        print("UI mise à jour")
    }
    
    deinit { print("ViewController désalloué") }
}

// Cas 4: Unowned vs Weak
class Controller {
    var callback: (() -> Void)?
    
    func setup() {
        // unowned : garantie que self existe toujours pendant la closure
        callback = { [unowned self] in
            self.handleEvent()
        }
    }
    
    func handleEvent() { }
    deinit { print("Controller désalloué") }
}

Tableau Comparatif

Aspect Référence Forte Weak Unowned
Incrémente ARC ✅ Oui ❌ Non ❌ Non
Devient nil N/A ✅ Oui ❌ Non
Crash si nil N/A ❌ Non ✅ Oui
Cas d'usage Parent-Child normal Cycles entre objets Parent retient Child
Overhead Minimal Minimal Minimal
Thread-safety Oui Oui (atomic) Non

Astuce Expert

Utilisez un memory debugger dans Xcode (Debug → Memory Graph) pour visualiser les références en temps réel. Cela permet d'identifier instantanément les références circulaires sans attendre une fuite observable. De plus, ajoutez deinit à tous vos objets importants avec un print() pour tracer les allocations/déallocations pendant le développement.

⚠️ Attention Critique

Ne convertissez JAMAIS une reference weak en unowned simplement pour éviter le pattern guard let. Une reference unowned incorrecte causera un Unexpectedly found nil crash qui est bien pire qu'une fuite mémoire. Validez TOUJOURS le cycle de vie avant d'utiliser unowned.


2. Value Types vs Reference Types : Implications de Performance

Définition

Swift propose deux catégories fondamentales de types : les value types (structs, enums, tuples) qui sont copiés lors de l'assignement, et les reference types (classes) qui partagent la même instance en mémoire. Cette distinction impacte profondément la performance, le multithreading et la gestion mémoire. Choisir le bon type selon le contexte est critique pour optimiser une application.

Explication Détaillée

Les value types sont alloués sur la pile (stack), ce qui est très rapide et auto-libérés quand ils sortent de portée. Les reference types vont sur le heap, nécessitant l'allocation/désallocation via ARC. Cependant, Swift a une optimisation appelée Copy-on-Write (CoW) : les collections (Array, Dictionary, Set) utilisent des value types mais partagent le buffer interne jusqu'à la première mutation, évitant les copies inutiles.

La plupart des value types (primitives, petites structures) sont plus performants. Mais les collections grandes et les types avec beaucoup de champs bénéficient de CoW. Pour les domaines d'objets complexes (modèles métier), les classes offrent l'identité et la mutabilité partagée nécessaires, au coût de la complexité ARC.

Bloc Code

// Cas 1: Value type simple - stack allocation
struct Point {
    var x: Int
    var y: Int
}

var p1 = Point(x: 10, y: 20)
var p2 = p1
p2.x = 30
print(p1.x)  // 10 - p1 inchangé (copie)

// Cas 2: Reference type - heap allocation
class MutablePoint {
    var x: Int
    var y: Int
    init(x: Int, y: Int) { self.x = x; self.y = y }
}

var mp1 = MutablePoint(x: 10, y: 20)
var mp2 = mp1
mp2.x = 30
print(mp1.x)  // 30 - même objet!

// Cas 3: Copy-on-Write avec Array
var arr1: [Int] = [1, 2, 3, 4, 5]
var arr2 = arr1
// À ce stade, arr1 et arr2 partagent le buffer interne

print(isKnownUniquelyReferenced(&arr1))  // true si pas de partage
arr2.append(6)  // Déclenche la copie ici!
// Maintenant arr1 et arr2 ont des buffers séparés

// Cas 4: Performance comparison
func testValueTypePerformance() {
    let iterations = 1_000_000
    var points: [Point] = []
    
    let startValue = Date()
    for i in 0..<iterations {
        var p = Point(x: i, y: i)
        p.x += 1
        points.append(p)
    }
    let timeValue = Date().timeIntervalSince(startValue)
    
    print("Value types: \(timeValue)s")
}

func testReferenceTypePerformance() {
    let iterations = 1_000_000
    var points: [MutablePoint] = []
    
    let startRef = Date()
    for i in 0..<iterations {
        let p = MutablePoint(x: i, y: i)
        p.x += 1
        points.append(p)
    }
    let timeRef = Date().timeIntervalSince(startRef)
    
    print("Reference types: \(timeRef)s")
    // Les références sont généralement plus lentes (heap alloc + ARC)
}

// Cas 5: Struct géante - problématique
struct HugeData {
    var data1: [Int]
    var data2: [String]
    var data3: [Double]
    var data4: [Bool]
    // Chaque copie duplique tout cela!
}

let hugeData = HugeData(data1: Array(0..<1000), 
                         data2: (0..<1000).map { "item_\($0)" },
                         data3: (0..<1000).map { Double($0) },
                         data4: (0..<1000).map { $0.isMultiple(of: 2) })
let copy = hugeData  // Copie coûteuse!

// Cas 6: Indirection avec reference wrapper
class Ref<T> {
    let value: T
    init(_ value: T) { self.value = value }
}

struct OptimizedHugeData {
    let ref: Ref<(
        data1: [Int],
        data2: [String],
        data3: [Double],
        data4: [Bool]
    )>
}

// Les copies sont maintenant rapides (seulement une reference)

Tableau Comparatif

Critère Value Type (Struct) Reference Type (Class)
Allocation Stack Heap
Copie Complète (CoW pour collections) Partage de référence
Déallocation Automatique (scope) ARC (complexe)
Identité Basée sur valeur Basée sur identité
Mutabilité partagée Difficile Native
Performance (simple) ⚡ Excellente Bonne
Performance (grosse collection) Moyenne (avec CoW) Rapide
Multithreading Safe (copy) Nécessite synchronisation
Héritage Non supporté Supporté

Astuce Expert

Utilisez le Instruments (Profile → Allocations) pour mesurer l'impact réel. Souvent, le coût des allocations heap et du garbage collection est bien plus significatif que les copies de structs. Les structs légères sont souvent plus performantes même avec plusieurs copies. Profiler avant d'optimiser prématurément.

⚠️ Attention Critique

Ne créez jamais de reference cycles involontaires avec des classes. Si vous passez une classe à une closure, capturez-la toujours avec [weak self] ou [unowned self] sauf garantie absolue d'absence de cycle. Le pattern de capturer fortement dans les ViewControllers est une source classique de fuites mémoire.


3. Analyse de Performance : Profiling et Optimization Avancée

Définition

Le profiling consiste à mesurer quantitativement la consommation de ressources (CPU, mémoire, énergie, I/O) d'une application pour identifier les goulots d'étranglement. Swift offre des outils sophistiqués comme Instruments, os_signpost, et MetricKit pour capturer ces données. L'optimisation basée sur des données plutôt que sur des suppositions est la clé d'une performance durable.

Explication Détaillée

Le profiling révèle souvent que les suppositions sur les performances sont fausses. Une opération qu'on croit lente peut être rapide, et vice-versa. Les sources courantes de problèmes incluent : allocations excessives, locks contention en multithreading, I/O synchrone bloquant, et algorithmes inefficaces. Swift fourni plusieurs mécanismes : Instruments pour un profiling détaillé, os_signpost pour marquer des sections custom, et MetricKit pour collecter des données en production.

L'optimisation efficace suit une démarche scientifique : mesurer l'état actuel, identifier la pire partie, optimiser, re-mesurer. Les micro-optimisations sans impact mesurable sont du time-waste. De plus, les optimisations Swift niveau compilation (optimisation release vs debug) ont souvent plus d'impact que les changements manuels.

Bloc Code

import os
import Foundation

// Cas 1: Signposting basique pour tracer les sections
let logger = OSLog(subsystem: "com.example.app", category: "Performance")

func expensiveOperation() {
    os_signpost(.begin, log: logger, name: "ExpensiveOp")
    
    var sum = 0
    for i in 0..<100_000_000 {
        sum += i
    }
    
    os_signpost(.end, log: logger, name: "ExpensiveOp")
}

// Visualisez dans Instruments : System Trace ou Points of Interest

// Cas 2: Mesure manuelle avec DispatchTime
func measurePerformance(label: String, block: () -> Void) {
    let start = DispatchTime.now()
    block()
    let end = DispatchTime.now()
    let nanoTime = end.uptimeNanoseconds - start.uptimeNanoseconds
    let timeInterval = Double(nanoTime) / 1_000_000_000
    print("\(label) took \(timeInterval) seconds")
}

// Usage
measurePerformance(label: "Array sort") {
    let arr = (0..<10_000).shuffled()
    _ = arr.sorted()
}

// Cas 3: Profiling allocations avec os_log
os_log("Current memory usage: %{public}d MB", 
       type: .debug, 
       Int(ProcessInfo.processInfo.physicalMemory / 1_000_000))

// Cas 4: Détection de main thread blocking
func checkMainThreadBlocking() {
    DispatchQueue.global().async {
        // Simuler work sur background
        sleep(1)
        
        DispatchQueue.main.async {
            let startTime = CFAbsoluteTimeGetCurrent()
            
            // Opération longue sur main thread
            var sum = 0
            for i in 0..<1_000_000_000 {
                sum += i
            }
            
            let elapsed = CFAbsoluteTimeGetCurrent() - startTime
            print("Main thread blocked for \(elapsed)s")
            // Ceci est mauvais - l'UI freezera!
        }
    }
}

// Cas 5: Analyse de complexité avec large dataset
func analyzeAlgorithmicComplexity() {
    let sizes = [100, 1_000, 10_000, 100_000]
    
    for size in sizes {
        let arr = (0..<size).shuffled()
        
        measurePerformance(label: "O(n²) for size \(size)") {
            for i in 0..<arr.count {
                for j in i..<arr.count {
                    _ = arr[i] + arr[j]
                }
            }
        }
    }
    // O(n²) devient rapidement problématique!
}

// Cas 6: Memory leak detection avec weak tracking
class MemoryLeakDetector {
    weak var weakSelf: MemoryLeakDetector?
    
    func checkLeak() {
        if weakSelf != nil {
            print("Object still alive (potential leak)")
        } else {
            print("Object was deallocated")
        }
    }
}

var detector: MemoryLeakDetector? = MemoryLeakDetector()
detector?.weakSelf = detector
detector = nil
// Lancer avec Memory Graph pour voir les allocations non-libérées

// Cas 7: Optimization avec Foundation's Measurements
import Foundation

struct PerformanceMetrics {
    var cpuUsage: Double = 0
    var memoryUsage: UInt64 = 0
    var timeElapsed: TimeInterval = 0
}

func captureMetrics(operation: () -> Void) -> PerformanceMetrics {
    let processInfo = ProcessInfo.processInfo
    let startTime = Date()
    let startMemory = processInfo.physicalMemory
    
    operation()
    
    let endTime = Date()
    let endMemory = processInfo.physicalMemory
    
    return PerformanceMetrics(
        cpuUsage: Double(processInfo.activeProcessorCount),
        memoryUsage: endMemory - startMemory,
        timeElapsed: endTime.timeIntervalSince(startTime)
    )
}

// Cas 8: Batch processing pour éviter allocations
func processDataOptimized(_ items: [String]) {
    let batchSize = 100
    var batch: [String] = []
    batch.reserveCapacity(batchSize)  // Pré-allouer!
    
    for item in items {
        batch.append(item)
        if batch.count == batchSize {
            processBatch(batch)
            batch.removeAll(keepingCapacity: true)  // Garder la capacité
        }
    }
    
    if !batch.isEmpty {
        processBatch(batch)
    }
}

func processBatch(_ batch: [String]) {
    // Traiter le batch
}

Tableau Comparatif

Outil Cas d'Usage Profondeur Grain
Instruments (Allocations) Memory leaks Très détaillé Par allocation
os_signpost Custom sections Moyen Custom markers
DispatchTime Benchmarks simples Basique Code blocks
MetricKit Production data Agrégé App-wide
Xcode Debug Development Basique Breakpoints
Shark/sample CPU hot spots Très détaillé Stack traces

Astuce Expert

Compilez TOUJOURS en Release mode (optimizations enabled) avant de benchmarker. Swift debug builds incluent des vérifications et désactivent les optimisations, donnant des résultats 10-100x plus lents. Utilisez xcrun simctl spawn booted log stream --level debug pour streamer les os_signpost en temps réel pendant les tests.

⚠️ Attention Critique

Les optimisations prématurées basées sur l'intuition causent souvent plus de mal que de bien. Toujours mesurer d'abord, optimiser ensuite. De plus, une "optimisation" qui rend le code moins lisible doit être justifiée par un gain mesurable >10%. Les micro-optimisations <1% de gain ne valent presque jamais la complexité additionnelle.


4. Concurrency en Swift : Async/Await, Actors et Thread Safety

Définition

La concurrency en Swift 5.5+ est gérée via le système structured concurrency avec async/await, les actors, et les task groups. Contrairement aux closures et callbacks traditionnels, ces constructions garantissent un modèle de programmation plus sûr et prévisible. Les actors fournissent l'isolation de données par défaut, éliminant les race conditions et les besoins de locks explicites dans la plupart des cas.

Explication Détaillée

Le problème classique du multithreading Swift était la complexité : callbacks, GCD queues, locks manuels, race conditions subtiles. async/await élimine beaucoup de cette complexité en permettant du code séquentiel qui peut être suspendu sans bloquer le thread. Les actors appliquent l'isolation des données : un seul task peut exécuter une méthode d'un actor à la fois, éliminant besoin de locks externes.

Cependant, l'isolation des acteurs peut créer des deadlocks si mal utilisée. De plus, certains problèmes restent difficiles à résoudre avec actors (dépendances circulaires d'actors). La performance est un autre facteur : suspendre/reprendre des tasks a un coût, bien que minimal en général.

Bloc Code

import Foundation

// Cas 1: Async/await basique
func fetchUserData(userId: Int) async throws -> String {
    // Simule une requête réseau
    try await Task.sleep(nanoseconds: 1_000_000_000)
    return "User_\(userId)"
}

func demonstrateAsyncAwait() async {
    do {
        let user = try await fetchUserData(userId: 42)
        print("Fetched: \(user)")
    } catch {
        print("Error: \(error)")
    }
}

// Appeler depuis main avec top-level await
// await demonstrateAsyncAwait()

// Cas 2: Actor pour thread-safe state
actor UserCache {
    private var cache: [Int: String] = [:]
    
    func get(userId: Int) -> String? {
        return cache[userId]
    }
    
    func set(userId: Int, value: String) {
        cache[userId] = value
    }
    
    func clear() {
        cache.removeAll()
    }
}

// Usage - automatiquement thread-safe!
@main
struct MainApp {
    static func main() async {
        let userCache = UserCache()
        
        async let user1 = userCache.get(userId: 1)
        async let user2 = userCache.get(userId: 2)
        
        _ = await (user1, user2)
        // Pas de data races possibles
    }
}

// Cas 3: Acteur avec données complexes
actor NetworkManager {
    private var activeRequests: [String: URLSessionTask] = [:]
    private let session = URLSession.shared
    
    func fetch(url: String) async throws -> Data {
        if let existingTask = activeRequests[url] {
            // Réutiliser la tâche en cours
            // Ce code est safe - on est isolé par l'actor
            return try await existingTask.data.0
        }
        
        let (data, _) = try await session.data(from: URL(string: url)!)
        return data
    }
    
    nonisolated func getActiveRequestCount() -> Int {
        // nonisolated - accès sans contention
        return activeRequests.count
    }
}

// Cas 4: Task groups pour concurrence structurée
func fetchMultipleUsers(ids: [Int]) async throws -> [String] {
    return try await withThrowingTaskGroup(of: String.self) { group in
        for id in ids {
            group.addTask {
                return try await fetchUserData(userId: id)
            }
        }
        
        var results: [String] = []
        for try await user in group {
            results.append(user)
        }
        return results
    }
}

// Cas 5: MainActor pour UI updates
@MainActor
class ViewController {
    var label: UILabel?
    
    func updateUI(with text: String) {
        // Garanti d'être sur le main thread
        label?.text = text
    }
    
    func loadDataAndUpdate() async {
        let data = try? await fetchUserData(userId: 1)
        await updateUI(with: data ?? "Error")
    }
}

// Cas 6: Acteur hiérarchique - éviter deadlock
actor DatabaseConnection {
    private var isConnected = false
    
    func connect() async {
        await Task.sleep(nanoseconds: 100_000_000)
        isConnected = true
    }
    
    func query(_ sql: String) async throws -> String {
        guard isConnected else {
            throw NSError(domain: "DB", code: -1)
        }
        return "Result of: \(sql)"
    }
    
    func safeQuery(_ sql: String) async throws -> String {
        if !isConnected {
            await connect()
        }
        return try await query(sql)
    }
}

// Cas 7: Cancellation token
func longRunningTask() async throws {
    for i in 0..<100 {
        try Task.checkCancellation()  // Vérifier si annulé
        
        try await Task.sleep(nanoseconds: 100_000_000)
        print("Step \(i)")
    }
}

func demonstrateCancellation() async {
    let task = Task {
        try await longRunningTask()
    }
    
    try await Task.sleep(nanoseconds: 350_000_000)
    task.cancel()  // Annuler après 350ms
    
    do {
        try await task.value
    } catch is CancellationError {
        print("Task was cancelled")
    }
}

// Cas 8: Éviter deadlocks - pattern à risque
// ⚠️ CAN DEADLOCK!
actor BadPattern {
    func methodA() async {
        await methodB()  // Appeler une autre méthode du même actor
    }
    
    func methodB() async {
        print("B")
    }
}

// CORRECT: Non-isolated pour synchronisation
actor GoodPattern {
    nonisolated func externalCall(_ callback: @escaping () async -> Void) async {
        await callback()
    }
}

// Cas 9: Sendsble types pour sécurité
@Sendable
struct SendableData: Sendable {
    let id: Int
    let name: String
}

// Envoyable entre tasks sans danger
func passData() async {
    let data = SendableData(id: 1, name: "Test")
    
    await Task {
        print(data)  // Safe - SendableData peut être envoyé
    }.value
}

// Cas 10: Actors et réduction de contention
actor Counter {
    private var value = 0
    
    func increment() async {
        value += 1
    }
    
    func getValue() async -> Int {
        return value
    }
}

func testCounterPerformance() async {
    let counter = Counter()
    
    async let _ = (0..<1000).map { _ in Task { await counter.increment() } }
    
    let finalValue = await counter.getValue()
    print("Final counter: \(finalValue)")
}

Tableau Comparatif

Pattern Thread-Safety Complexité Performance Cas d'Usage
Manual Locks ✅ (si correct) 🔴 Haute ⚠️ Variable Legacy code
GCD Queues ✅ (serial) 🟡 Moyenne ✅ Bonne Compatibility
Async/Await ✅ Automatique 🟢 Basse ✅ Excellente Modern code
Actors ✅ Automatique 🟢 Basse ✅ Excellente Shared state
@MainActor ✅ UI-safe 🟢 Basse ✅ Bonne UI updates

Astuce Expert

Préférez nonisolated pour les méthodes read-only ou n'ayant pas besoin d'accès à l'état actor. Cela réduit la contention et améliore la parallélisabilité. De plus, utilisez Task.sleep(nanoseconds:) pour des délais plutôt que Thread.sleep() qui bloque le thread entier. Les actors sont souvent plus rapides que les locks car il n'y a pas de busy-waiting.

⚠️ Attention Critique

Même avec async/await, les deadlocks sont possibles si un actor A appelle un actor B qui appelle A. De plus, convertir du code GCD/callback vers async/await peut révéler des race conditions cachées. Testez intensivement avec ThreadSanitizer (-fsanitize=thread) pour vérifier. Enfin, les actors ne sont pas une balle d'argent : certains problèmes (graphes de dépendances complexes) restent difficiles même avec actors.


5. Debugging Avancé et Outils Systèmes

Définition

Le debugging avancé en iOS dépasse les simples breakpoints pour inclure des techniques comme le LLDB scripting, l'inspection runtime via le debugger, l'Address Sanitizer pour détecter memory errors, et l'intégration avec les system frameworks. Ces outils permettent de diagnostiquer des bugs subtils, des race conditions, des corruptions mémoire, et des problèmes de performance impossible à voir avec du debugging classique.

Explication Détaillée

Le debugger Xcode utilise LLDB (Low Level Debugger), qui expose une API Python complète. Les commandes custom LLDB permettent d'inspecter l'état du programme à un niveau très fin. Les sanitizers (AddressSanitizer, ThreadSanitizer) instrumentent le code à la compilation pour détecter memory errors en runtime, révélant des bugs que des tests normaux manquent.

Les breakpoints symboliques permettent de casser sur certaines conditions système (ex: tous les appels à malloc). Les watchpoints observent les changements de mémoire. Le View Debugging révèle la hiérarchie de vues en 3D. Combiner ces outils systématiquement élimine les bugs "mystérieux".

Bloc Code

import Foundation
import Darwin

// Cas 1: LLDB commands basiques (dans le debugger console)
// (lldb) po someObject  // Print Object
// (lldb) p variable     // Print value
// (lldb) memory read 0x12345678  // Read raw memory
// (lldb) dis -f  // Disassemble

// Cas 2: Custom Python breakpoint commands
// Créer file: ~/lldb_commands.py
// command script import ~/lldb_commands.py
/*
def find_leaked_views(debugger, command, result, internal_dict):
    target = debugger.GetSelectedTarget()
    process = target.GetProcess()
    
    # Inspecter heap pour UIView non-libérées
    # Code Python complexe ici...
    result.SetStatus(lldb.eReturnStatusSuccessFinishResult)

def __lldb_init_module(debugger, internal_dict):
    debugger.HandleCommand('command script add -f lldb_commands.find_leaked_views findleaks')
*/

// Cas 3: Runtime type inspection
class TypeInspector {
    static func inspectType(_ object: AnyObject) {
        let mirror = Mirror(reflecting: object)
        print("Type: \(type(of: object))")
        print("Children:")
        for child in mirror.children {
            print("  - \(child.label ?? "unnamed"): \(type(of: child.value))")
        }
    }
}

struct TestData {
    let id: Int
    let name: String
}

// Usage
let testData = TestData(id:
Accédez à des centaines d'examens QCM — Découvrir les offres Premium