Span dans Swift 6.2 : accès mémoire sûr et performant (guide complet)

Découvrez le type Span de Swift 6.2 : un accès mémoire sûr aussi performant que les pointeurs non sécurisés. Guide complet avec RawSpan, OutputSpan, InlineArray et résultats de performance concrets.

Introduction : Pourquoi la sécurité mémoire compte autant en Swift

La gestion de la mémoire, c'est un peu le nerf de la guerre pour quiconque développe des logiciels performants. Pendant des décennies, C et C++ nous ont donné un contrôle total sur la mémoire — mais à quel prix. Des bugs parfois dévastateurs, une complexité qui monte en flèche, et un stress constant pour les équipes de maintenance.

Swift a toujours cherché un compromis élégant entre sécurité et performance. Et avec Swift 6.2, on franchit une étape vraiment significative grâce au type Span et sa famille de types associés.

Jusqu'ici, quand vous aviez besoin d'accéder directement à la mémoire contiguë en Swift, il fallait passer par les pointeurs non sécurisés — UnsafePointer, UnsafeBufferPointer, et compagnie. Ces types sont puissants, certes, mais ils placent toute la responsabilité de la sécurité mémoire sur vos épaules. Le résultat ? Des bugs subtils, des failles de sécurité, et un code qui devient pénible à maintenir dans les projets d'envergure.

Swift 6.2 change la donne avec Span, une abstraction qui offre un accès rapide et direct à la mémoire contiguë sans compromettre la sécurité mémoire. Le truc vraiment malin, c'est que Span garantit que la mémoire sous-jacente reste valide pendant toute la durée de son utilisation, et ces garanties sont vérifiées à la compilation, sans aucun coût à l'exécution. Dans cet article, on va explorer en profondeur cette nouvelle famille de types, comprendre comment elle fonctionne sous le capot, et voir comment l'exploiter concrètement dans vos projets.

Comprendre les problèmes des pointeurs non sécurisés

Avant de plonger dans Span, prenons un moment pour comprendre pourquoi les pointeurs non sécurisés posent problème. Ces types portent le préfixe Unsafe pour une bonne raison : ils contournent les mécanismes de protection du langage et exposent votre code à plusieurs catégories de bugs critiques.

Les bugs de type use-after-free

Un bug use-after-free survient lorsque vous accédez à la mémoire après qu'elle a été libérée. C'est particulièrement insidieux parce que ça peut sembler fonctionner correctement pendant un certain temps, puis provoquer des plantages aléatoires au pire moment possible.

// Exemple de bug use-after-free avec UnsafeBufferPointer
func dangerousAccess() {
    let pointer: UnsafeBufferPointer<Int>
    do {
        var array = [10, 20, 30, 40, 50]
        pointer = array.withUnsafeBufferPointer { $0 }
        // 'array' est libéré à la fin de ce bloc
    }
    // DANGER : accès à de la mémoire libérée !
    print(pointer[0]) // Comportement indéfini
}

Ici, le pointeur survit au-delà de la durée de vie du tableau qu'il référence. Le compilateur ne bronche pas, et le programme pourrait afficher n'importe quelle valeur — ou tout simplement planter. Honnêtement, c'est le genre de bug qui peut vous coûter des heures de débogage.

Les pointeurs pendants (dangling pointers)

Un pointeur pendant référence une zone mémoire qui n'est plus valide. C'est une variante du use-after-free, mais le problème devient encore plus vicieux lorsque la mémoire est réallouée pour un autre usage.

// Exemple de pointeur pendant
var data = UnsafeMutablePointer<Int>.allocate(capacity: 5)
data.initialize(repeating: 42, count: 5)
data.deallocate()

// 'data' est maintenant un pointeur pendant
// Toute utilisation ultérieure est un comportement indéfini
data[0] = 99 // DANGER : écriture dans une mémoire désallouée

Les débordements de tampon (buffer overflows)

Les débordements de tampon, c'est quand vous lisez ou écrivez au-delà des limites d'un buffer alloué. Ils représentent l'une des failles de sécurité les plus exploitées dans l'histoire de l'informatique (et ce n'est malheureusement pas près de changer).

// Exemple de débordement de tampon
let buffer = UnsafeMutableBufferPointer<UInt8>.allocate(capacity: 4)
buffer.initialize(repeating: 0)

// DANGER : écriture au-delà des limites du buffer
for i in 0..<10 {
    buffer[i] = UInt8(i) // Crash ou corruption mémoire pour i >= 4
}

buffer.deallocate()

Tous ces problèmes partagent un point commun : le compilateur Swift ne peut pas vous protéger quand vous utilisez les API Unsafe. C'est précisément ce que Span vient résoudre.

Présentation de la famille Span

Swift 6.2 n'introduit pas un seul type, mais toute une famille de types Span, chacun conçu pour un cas d'utilisation spécifique. Cette famille offre un accès sûr, sans copie, à la mémoire contiguë, tout en éliminant les catégories de bugs qu'on vient de décrire.

Span<T> : accès typé en lecture seule

Span<T> est le type le plus fondamental de la famille. Il fournit un accès en lecture seule à une séquence contiguë d'éléments de type T. Pensez-y comme une « vue » sûre sur un tableau ou toute autre collection contiguë, sans en prendre la propriété.

let nombres: [Int] = [1, 2, 3, 4, 5]
let span = nombres.span  // Span<Int>, lecture seule

// Accès sûr aux éléments
for i in 0..<span.count {
    print(span[i])
}

RawSpan : accès brut en lecture seule

RawSpan offre un accès en lecture seule au niveau des octets bruts, sans typage spécifique. C'est particulièrement utile pour le parsing de données binaires ou l'inspection de la représentation mémoire de vos données.

MutableSpan<T> : accès typé en lecture-écriture

MutableSpan<T> étend les capacités de Span<T> en autorisant les modifications. Il permet de lire et d'écrire des éléments individuels dans un buffer mutable emprunté de manière exclusive.

MutableRawSpan : accès brut mutable

MutableRawSpan est l'équivalent mutable de RawSpan. Il donne un accès en lecture-écriture au niveau des octets — utile pour les opérations de bas niveau comme la construction de paquets réseau ou la manipulation de formats binaires.

OutputSpan : pour initialiser de nouvelles collections

OutputSpan est un type spécialisé conçu pour l'initialisation de mémoire. Contrairement à MutableSpan, il peut modifier le nombre d'éléments initialisés, ce qui le rend idéal pour remplir progressivement un buffer nouvellement alloué.

UTF8Span : traitement Unicode spécialisé

UTF8Span est dédié au traitement sûr et efficace du texte encodé en UTF-8. Si vous travaillez beaucoup avec des chaînes de caractères au niveau des octets, c'est le type qu'il vous faut.

Point clé : Tous ces types utilisent la fonctionnalité ~Escapable (types non-échappables) de Swift pour lier leur durée de vie à celle de la collection source. Concrètement, le compilateur garantit à la compilation qu'un Span ne peut jamais survivre à la mémoire qu'il référence. Et le meilleur ? Aucun coût à l'exécution.

Comment fonctionne Span sous le capot

Alors, qu'est-ce qui rend Span à la fois sûr et performant ? La clé réside dans deux concepts fondamentaux : les types non-échappables et la dépendance de durée de vie.

Types non-échappables et dépendance de durée de vie

En Swift classique, toute valeur peut être stockée dans une variable, retournée par une fonction, ou capturée dans une closure. On dit que ces valeurs sont échappables. Span, en revanche, est déclaré comme ~Escapable, ce qui signifie qu'il ne peut pas s'échapper de la portée dans laquelle il a été créé.

En pratique, quand vous obtenez un Span à partir d'un tableau, le compilateur enregistre que ce Span dépend de la durée de vie du tableau. Si vous tentez d'utiliser le Span après que le tableau a été détruit ou modifié, le compilateur refuse tout simplement de compiler votre code. Pas de surprise à l'exécution.

Vérifications à la compilation avec zéro coût à l'exécution

C'est l'aspect le plus remarquable de Span. Toutes les garanties de sécurité sont appliquées à la compilation. Contrairement aux vérifications d'exclusivité dynamiques qui peuvent ajouter un surcoût à l'exécution, les contraintes de durée de vie de Span sont entièrement résolues par le compilateur. Le code généré est aussi efficace que si vous utilisiez directement des pointeurs non sécurisés.

Avouons-le, c'est plutôt impressionnant.

Exemples d'erreurs de compilation

Voyons concrètement comment le compilateur vous protège quand vous essayez de faire s'échapper un Span de manière inappropriée.

// INVALIDE : Le compilateur empêche l'échappement du span
func getHiddenSpanOfBytes() -> Span<UInt8> { }
// erreur: Cannot infer lifetime dependence...
// Le compilateur ne peut pas déterminer de quelle source
// le Span retourné dépendrait pour sa durée de vie.

Cette erreur survient parce que la fonction promet de retourner un Span<UInt8>, mais le compilateur n'a aucun moyen de savoir à quelle mémoire ce Span serait lié. Sans dépendance de durée de vie explicite, le code est rejeté.

// INVALIDE : Tentative de capture d'un Span dans une closure
func getHiddenSpanOfBytes() -> () -> Int {
    let array: [UInt8] = Array(repeating: 0, count: 128)
    let span = array.span
    return { span.count }  // erreur: span échappe sa portée
}
// Le span est lié à 'array', qui sera détruit quand la
// fonction retourne. La closure capturerait un span invalide.

Dans ce second exemple, le compilateur détecte que span serait capturé par la closure retournée, alors que le tableau sous-jacent sera libéré à la fin de la fonction. C'est exactement le type de bug use-after-free que les pointeurs non sécurisés ne peuvent pas prévenir, mais que Span élimine complètement.

Note technique : Les propriétés qui retournent un Span utilisent l'attribut @lifetime, une fonctionnalité expérimentale supportée dans Swift 6.2, qui permet d'exprimer explicitement la dépendance de durée de vie d'une valeur non-échappable. Par exemple, la propriété .span d'un Array exprime que le Span retourné dépend de la durée de vie du tableau emprunté.

Accéder à Span depuis les types standard

L'un des aspects les plus appréciables de Span, c'est son intégration native avec les types de la bibliothèque standard. Vous n'avez pas besoin de créer manuellement des Span : les collections courantes les exposent via des propriétés calculées.

La propriété .span

Les types suivants exposent une propriété .span qui retourne un Span<Element> :

  • Array<T> : le tableau standard de Swift
  • ArraySlice<T> : une tranche de tableau
  • ContiguousArray<T> : le tableau optimisé pour le stockage contigu
  • String.UTF8View : la vue UTF-8 d'une chaîne
  • InlineArray<N, T> : le nouveau type de tableau à taille fixe de Swift 6.2
  • Data : le type Foundation pour les données binaires
// Accès à un Span depuis un Array
let numbers: [Int] = [1, 2, 3, 4, 5]
let span = numbers.span  // Span<Int>

print(span.count)  // 5
print(span[0])     // 1
print(span[4])     // 5

// Le span est une vue en lecture seule sur le tableau
// Il ne copie pas les données et n'incrémente pas le compteur de références

La propriété .bytes pour RawSpan

Quand les éléments d'une collection sont conformes au protocole BitwiseCopyable (c'est-à-dire qu'ils peuvent être copiés bit par bit de manière sûre, comme les types numériques), vous pouvez également accéder à un RawSpan via la propriété .bytes.

// Accès à un RawSpan via .bytes
let data = Data([0x48, 0x65, 0x6C, 0x6C, 0x6F])
let byteSpan = data.span       // Span<UInt8>
let rawBytes = data.bytes       // RawSpan

print(byteSpan.count)  // 5 éléments UInt8
print(rawBytes.byteCount)  // 5 octets

// Accès aux octets bruts d'un tableau d'entiers
let integers: [Int32] = [1, 2, 3]
let rawView = integers.bytes  // RawSpan
print(rawView.byteCount)  // 12 octets (3 × 4 octets par Int32)

Cette intégration transparente signifie que vous pouvez adopter Span progressivement dans vos projets existants, simplement en accédant à la propriété .span de vos collections. Pas besoin de tout réécrire d'un coup.

Utiliser RawSpan pour le parsing binaire

Bon, passons aux choses sérieuses. L'un des cas d'utilisation les plus convaincants de la famille Span est le parsing de données binaires. Traditionnellement, ce type de travail nécessitait l'utilisation intensive de pointeurs non sécurisés, avec tous les risques qu'on connaît. RawSpan offre une alternative sûre et performante.

Exemple pratique : lecture séquentielle d'octets

Voici une extension sur RawSpan qui permet de lire des octets séquentiellement, en avançant le span après chaque lecture :

extension RawSpan {
    /// Lit un octet au début du RawSpan et avance la position.
    /// Retourne nil si le span est vide.
    mutating func readByte() -> UInt8? {
        guard !isEmpty else { return nil }
        let value = unsafeLoadUnaligned(as: UInt8.self)
        self = self._extracting(droppingFirst: 1)
        return value
    }

    /// Lit une valeur de type T au début du RawSpan.
    /// Retourne nil si le span ne contient pas assez d'octets.
    mutating func read<T: BitwiseCopyable>(as type: T.Type) -> T? {
        guard byteCount >= MemoryLayout<T>.size else { return nil }
        let value = unsafeLoadUnaligned(as: T.self)
        self = self._extracting(droppingFirst: MemoryLayout<T>.size)
        return value
    }
}

L'approche est élégante : le RawSpan se réduit à chaque lecture, et l'accès au-delà des limites est naturellement empêché par la vérification guard !isEmpty. Contrairement aux approches basées sur les pointeurs où il faut manuellement gérer un offset, ici le span lui-même encode la position courante. Personnellement, je trouve ce pattern beaucoup plus lisible que l'approche traditionnelle avec des pointeurs et des offsets manuels.

Exemple de parsing d'un en-tête binaire

struct FileHeader {
    var magic: UInt32
    var version: UInt16
    var flags: UInt16
    var dataLength: UInt32
}

func parseHeader(from data: Data) -> FileHeader? {
    var bytes = data.bytes  // RawSpan

    guard let magic = bytes.read(as: UInt32.self),
          let version = bytes.read(as: UInt16.self),
          let flags = bytes.read(as: UInt16.self),
          let dataLength = bytes.read(as: UInt32.self) else {
        return nil  // Pas assez de données
    }

    return FileHeader(
        magic: magic,
        version: version,
        flags: flags,
        dataLength: dataLength
    )
}

La bibliothèque swift-binary-parsing d'Apple

Apple a également publié la bibliothèque open-source swift-binary-parsing, construite sur les fondations de Span et RawSpan. Elle fournit des primitives de haut niveau pour construire des parseurs binaires sûrs et efficaces — gestion de l'alignement mémoire, endianness, formats de taille variable, le tout avec les garanties de sécurité de Span. Si vous devez parser des formats binaires complexes, c'est clairement le point de départ recommandé.

OutputSpan : Initialisation sécurisée de collections

OutputSpan est peut-être le membre le moins intuitif de la famille Span, mais c'est aussi l'un des plus puissants. Là où MutableSpan vous permet de modifier des éléments déjà initialisés, OutputSpan vous permet de construire progressivement le contenu d'un buffer fraîchement alloué.

Différence avec MutableSpan

La distinction clé :

  • MutableSpan<T> : tous les éléments sont déjà initialisés. Vous pouvez les lire et les modifier, mais pas changer le nombre d'éléments.
  • OutputSpan : le buffer commence vide. Vous ajoutez des éléments un par un, et le nombre d'éléments initialisés croît au fur et à mesure.

Ça résout un problème classique : comment initialiser efficacement un grand buffer sans passer par un tableau intermédiaire ou une allocation inutile ?

Exemple pratique : décodage de données de pixels

Voici un exemple concret tiré du décodage d'images, où OutputSpan est utilisé pour écrire progressivement des données de pixels :

let pixelData = Data(rawCapacity: totalBytes) { outputSpan in
    while outputSpan.count < totalBytes {
        guard let nextPixel = parsePixel(from: &input) else { break }

        switch nextPixel {
        case .run(let count):
            // Répéter le pixel précédent 'count' fois
            for _ in 0..<count {
                previousPixel.write(to: &outputSpan, channels: header.channels)
            }
        case .diff(let dr, let dg, let db):
            // Décoder un pixel à partir de différences
            let pixel = applyDelta(to: previousPixel, dr: dr, dg: dg, db: db)
            pixel.write(to: &outputSpan, channels: header.channels)
        default:
            // Décoder un pixel complet
            decodeSinglePixel(from: nextPixel)
                .write(to: &outputSpan, channels: header.channels)
        }
    }
}

OutputSpan élimine le besoin d'un tableau intermédiaire. Les données de pixels sont écrites directement dans le buffer final, sans allocation supplémentaire. Lors de la session WWDC25 consacrée à ce sujet, Apple a démontré que l'adoption d'OutputSpan rendait une opération de parsing six fois plus rapide — et tout ça sans toucher aux pointeurs non sécurisés.

Bon à savoir : Avant OutputSpan, la seule manière sûre d'initialiser un buffer progressivement était Array.init(unsafeUninitializedCapacity:initializingWith:), une API explicitement marquée « unsafe ». OutputSpan remplace cette approche par une alternative entièrement sûre.

Span et InlineArray : le duo gagnant en performance

Si Span offre un accès sûr à la mémoire contiguë, InlineArray est son compagnon idéal pour maximiser les performances. Également introduit dans Swift 6.2, InlineArray est un type de tableau à taille fixe déterminée à la compilation, qui stocke ses éléments directement dans la pile (stack) plutôt que dans le tas (heap).

Pourquoi cette combinaison est-elle si efficace ?

Pour comprendre l'impact, regardons ce qui se passe avec un Array standard :

  1. Le tableau alloue de la mémoire sur le tas (allocation dynamique)
  2. Chaque passage en paramètre implique un comptage de références (retain/release)
  3. La sémantique copy-on-write ajoute des vérifications d'unicité à chaque mutation

Avec InlineArray, tous ces coûts disparaissent :

  • Zéro allocation sur le tas : les données vivent directement dans la pile
  • Zéro comptage de références : pas de retain ni de release
  • Zéro copie-sur-écriture : les copies sont des copies de valeur directes

Des chiffres qui parlent

Lors de l'analyse de performance d'un décodeur d'images présentée à la WWDC25, les profilers ont révélé que swift_retain et swift_release représentaient chacun environ 7% du temps d'exécution total. En remplaçant un Array par un InlineArray<64, RGBAPixel> pour un cache de pixels de taille fixe, ces 14% de surcoût ont été éliminés d'un seul coup. Ça fait réfléchir, non ?

// Avant : Array avec allocation dynamique et comptage de références
var pixelCache: [RGBAPixel] = Array(repeating: .zero, count: 64)

// Après : InlineArray sans allocation ni comptage de références
var pixelCache: InlineArray<64, RGBAPixel> = .init(repeating: .zero)

// Accès via Span pour une lecture sûre et performante
let cacheSpan = pixelCache.span
let cachedPixel = cacheSpan[hashIndex]

Exemple complet combinant InlineArray et Span

struct PixelDecoder {
    // Cache de pixels stocké directement dans la pile
    var pixelCache: InlineArray<64, RGBAPixel> = .init(repeating: .zero)
    var previousPixel: RGBAPixel = .defaultPixel

    mutating func decode(from input: RawSpan) -> [RGBAPixel] {
        var remaining = input
        var result: [RGBAPixel] = []
        result.reserveCapacity(remaining.byteCount / 4)

        while let byte = remaining.readByte() {
            let pixel = decodePixel(byte, cache: pixelCache.span)
            result.append(pixel)

            // Mise à jour du cache via MutableSpan
            pixelCache[pixel.hashIndex] = pixel
            previousPixel = pixel
        }

        return result
    }
}

La combinaison d'InlineArray pour le stockage et de Span pour l'accès en lecture crée un duo redoutablement efficace. Les données restent dans la pile, les lectures sont directes sans indirection, et la sécurité mémoire est toujours garantie par le compilateur.

Interopérabilité avec C et C++

L'un des défis majeurs des projets Swift modernes, c'est l'interopérabilité avec le code C et C++ existant. Les interfaces entre Swift et C/C++ impliquent traditionnellement des pointeurs bruts, ce qui crée une sorte de « zone de danger » à la frontière entre les langages. Swift 6.2, combiné avec les annotations appropriées dans les en-têtes C/C++, permet désormais de remplacer les pointeurs par des Span au niveau de ces interfaces.

Annotations des en-têtes C

Le mécanisme repose sur l'annotation des en-têtes C avec des attributs de bornes et de durée de vie. Les deux annotations principales :

  • __counted_by(n) : indique qu'un pointeur pointe vers un buffer de n éléments
  • __noescape : indique que le pointeur ne survit pas à l'appel de la fonction
// En-tête C annoté (image_processing.h)
#include <ptrcheck.h>

// Avant : interface non sécurisée
void process_pixels(const uint8_t *data, size_t count);

// Après : interface annotée pour une utilisation sûre avec Span
void process_pixels(const uint8_t *data __counted_by(count),
                    size_t count) __noescape;

Quand le compilateur Swift importe cet en-tête annoté, il génère automatiquement une surcharge sûre de la fonction :

// Signature générée automatiquement par le compilateur Swift
// En plus de la signature unsafe classique :
func process_pixels(_ data: UnsafePointer<UInt8>, _ count: Int)

// Le compilateur génère également :
func process_pixels(_ data: Span<UInt8>)

// Utilisation côté Swift
let imageData: [UInt8] = loadImageBytes()
process_pixels(imageData.span)  // Appel sûr, sans conversion manuelle

Pour les fonctions dont la valeur de retour référence la mémoire d'un paramètre, l'annotation lifetimebound (via l'en-tête lifetimebound.h) permet au compilateur de générer des surcharges qui retournent un Span avec les dépendances de durée de vie appropriées.

Conseil pratique : Si vous maintenez une bibliothèque C ou C++ utilisée depuis Swift, ajoutez ces annotations progressivement. Chaque fonction annotée génère automatiquement une interface Span sûre côté Swift — ça permet une migration incrémentale vers un code plus sûr, sans tout casser d'un coup.

Mode de sécurité mémoire stricte

En complément de Span, Swift 6.2 introduit un mode de sécurité mémoire stricte optionnel. Ce mode va plus loin que les protections habituelles en signalant explicitement chaque utilisation de constructions non sécurisées dans votre code.

Principe de fonctionnement

Quand le mode strict est activé, le compilateur inverse sa présomption de sécurité. Par défaut, Swift considère que le code est sûr sauf indication contraire. En mode strict, tout type ou API qui n'est pas explicitement reconnu comme sûr est traité comme potentiellement dangereux, et le compilateur émet un diagnostic.

Concrètement, chaque utilisation de :

  • UnsafePointer, UnsafeMutablePointer
  • UnsafeBufferPointer, UnsafeMutableBufferPointer
  • Unmanaged
  • Toute API marquée @unsafe

...doit être accompagnée du mot-clé unsafe pour compiler. C'est une façon de forcer les développeurs à reconnaître explicitement chaque point de non-sécurité dans leur codebase.

Comment activer le mode strict

Plusieurs options s'offrent à vous :

  1. Dans Xcode : réglez le paramètre de build « Strict Memory Safety » sur « Yes »
  2. Via le compilateur : passez le drapeau -strict-memory-safety
  3. Dans Package.swift : ajoutez le réglage au niveau de la cible
// Package.swift - Activation du mode strict
.target(
    name: "MonModule",
    dependencies: [],
    swiftSettings: [
        .enableExperimentalFeature("StrictMemorySafety")
    ]
)

Exemple de code en mode strict

// En mode strict, ceci génère un avertissement :
let ptr = UnsafeMutablePointer<Int>.allocate(capacity: 10)

// Vous devez reconnaître explicitement l'utilisation non sécurisée :
let ptr = unsafe UnsafeMutablePointer<Int>.allocate(capacity: 10)

// Avec Span, aucun marquage n'est nécessaire car c'est sûr par conception :
let array = [1, 2, 3, 4, 5]
let span = array.span  // Aucun avertissement, c'est sûr !

Déjà en production : Apple utilise ce mode dans des composants critiques, notamment WebKit et un sous-système de l'application Messages, qui traitent des données non fiables provenant de sources extérieures. Ce mode est particulièrement recommandé pour les parseurs de formats de fichiers, le traitement de données réseau, les composants cryptographiques — bref, tout ce qui touche à la sécurité.

Gains de performance réels

La théorie, c'est bien. Mais qu'en est-il des résultats concrets ? La session WWDC25 « Improve memory usage and performance with Swift » a présenté une étude de cas qui illustre parfaitement le potentiel de Span.

L'étude de cas : un décodeur d'images QOI

Apple a utilisé un décodeur d'images au format QOI (Quite OK Image) comme banc d'essai. L'appli originale chargeait une petite icône instantanément, mais prenait plusieurs secondes pour décoder une photo de grande taille. L'optimisation s'est déroulée en plusieurs étapes :

  1. Correction algorithmique : suppression d'un algorithme quadratique qui recréait des tableaux à chaque itération
  2. Adoption d'InlineArray : remplacement d'un Array de 64 éléments par un InlineArray<64, RGBAPixel>, éliminant allocations et comptage de références
  3. Migration vers RawSpan : remplacement de Data par RawSpan pour le parsing, éliminant le surcoût du comptage de références à chaque accès
  4. Utilisation d'OutputSpan : remplacement de l'initialisation progressive via un tableau par un OutputSpan, supprimant les allocations intermédiaires

Résultats chiffrés

Les résultats cumulés sont franchement impressionnants :

  • Réduction des allocations : de près d'un million d'allocations à une poignée, pour un total d'à peine deux mégaoctets
  • Élimination du comptage de références : les 14% de temps CPU consommés par swift_retain/swift_release ont été supprimés
  • Vitesse de parsing : six fois plus rapide grâce à RawSpan et OutputSpan
  • Amélioration totale : en combinant toutes les optimisations, le décodeur est devenu plus de 700 fois plus rapide

Nuance importante : L'amélioration de 700x est le résultat cumulé de toutes les optimisations, correction algorithmique incluse. Span seul ne multipliera pas magiquement vos performances par 700, mais il élimine systématiquement les surcoûts de gestion mémoire qui, dans des boucles critiques, peuvent représenter une part significative du temps d'exécution.

Où se cachent les surcoûts éliminés par Span ?

Pour bien cerner l'impact, voici les catégories de surcoûts que Span élimine :

  • Comptage de références : chaque fois qu'un Array ou un Data est passé en paramètre, Swift incrémente et décrémente un compteur atomique. Dans une boucle traitant des millions d'octets, ces opérations s'accumulent vite.
  • Vérifications d'unicité copy-on-write : avant chaque mutation d'un Array, Swift vérifie si le buffer est partagé. Span, étant une vue empruntée, n'a pas besoin de cette vérification.
  • Allocations dynamiques : les tableaux intermédiaires créés pendant le décodage nécessitent des allocations sur le tas. OutputSpan permet d'écrire directement dans le buffer final.
  • Indirections mémoire : un Array stocke un pointeur vers son buffer sur le tas. InlineArray stocke les données dans la pile, éliminant un niveau d'indirection.

Bonnes pratiques et recommandations

Maintenant que vous comprenez le fonctionnement de Span et son impact, voici des recommandations concrètes pour l'intégrer dans vos projets.

Quand utiliser Span plutôt qu'Array

Span n'est pas un remplacement universel pour Array. Voici comment choisir :

  • Utilisez Span quand vous avez besoin d'un accès en lecture seule à des données contiguës, notamment dans les paramètres de fonctions. Si votre fonction ne fait que lire les données, acceptez un Span<T> plutôt qu'un [T].
  • Utilisez Array quand vous devez stocker des données, les faire persister au-delà d'un appel de fonction, ou les modifier avec la sémantique copy-on-write.
  • Utilisez InlineArray + Span pour les buffers de taille fixe connue à la compilation, accédés fréquemment dans des boucles critiques.

Profilage avant optimisation

Avant de migrer du code vers Span, utilisez Instruments pour identifier les véritables goulots d'étranglement. Trois signaux indiquent que Span pourrait aider :

  1. Des appels fréquents à swift_retain et swift_release dans les profils CPU
  2. Un grand nombre d'allocations dynamiques dans les profils d'allocations
  3. Des fonctions de parsing ou de traitement de données en tête des hotspots
// Exemple : refactorisation d'une fonction de parsing
// AVANT : accepte un Array (comptage de références à chaque appel)
func processPixels(_ pixels: [UInt8]) -> Result {
    for i in 0..<pixels.count {
        // traitement...
    }
}

// APRÈS : accepte un Span (zéro surcoût de gestion mémoire)
func processPixels(_ pixels: Span<UInt8>) -> Result {
    for i in 0..<pixels.count {
        // traitement identique, mais sans retain/release
    }
}

// L'appel change à peine :
let data: [UInt8] = loadData()
processPixels(data.span)

Remplacer le code à pointeurs non sécurisés

Si votre codebase contient des utilisations de withUnsafeBufferPointer ou withUnsafeBytes, envisagez de les remplacer par Span :

// AVANT : utilisation de pointeurs non sécurisés
func checksum(_ data: Data) -> UInt32 {
    data.withUnsafeBytes { buffer in
        var result: UInt32 = 0
        for byte in buffer {
            result = result & + UInt32(byte)
        }
        return result
    }
}

// APRÈS : utilisation de Span
func checksum(_ data: Data) -> UInt32 {
    let span = data.span
    var result: UInt32 = 0
    for i in 0..<span.count {
        result = result & + UInt32(span[i])
    }
    return result
}

Utiliser OutputSpan pour l'initialisation de buffers

Chaque fois que vous créez un buffer en le remplissant élément par élément, OutputSpan est l'outil qu'il vous faut :

// AVANT : pattern d'initialisation avec un Array temporaire
var result = [UInt8]()
result.reserveCapacity(expectedSize)
for item in source {
    result.append(transform(item))
}

// APRÈS : initialisation directe avec OutputSpan
let result = Data(rawCapacity: expectedSize) { outputSpan in
    for item in source {
        let transformed = transform(item)
        outputSpan.append(transformed)
    }
}

Préférer Span dans les boucles critiques

Les boucles qui traitent de grandes quantités de données sont les endroits où Span brille le plus. En obtenant un Span avant la boucle, vous amortissez toute surcharge et obtenez un accès direct à la mémoire pour toute la durée de l'itération.

// Optimisation d'une boucle de traitement audio
func processAudioBuffer(_ samples: [Float]) -> [Float] {
    let input = samples.span
    var output = [Float](repeating: 0, count: samples.count)

    // Accès direct via Span dans la boucle critique
    for i in 0..<input.count {
        output[i] = applyFilter(input[i])
    }

    return output
}

Récapitulatif des bonnes pratiques

  • Acceptez Span<T> dans les paramètres de fonctions qui n'ont besoin que d'un accès en lecture
  • Utilisez RawSpan pour le parsing de données binaires au lieu de withUnsafeBytes
  • Adoptez OutputSpan pour initialiser des buffers progressivement
  • Combinez InlineArray et Span pour les caches et buffers de taille fixe
  • Activez le mode de sécurité mémoire stricte pour les modules critiques
  • Profilez avec Instruments avant de migrer — identifiez d'abord les vrais hotspots
  • Annotez vos en-têtes C/C++ avec __counted_by et __noescape pour générer des interfaces Span automatiquement

Conclusion

Le type Span et sa famille représentent une avancée fondamentale dans l'évolution de Swift. Pour la première fois, les développeurs disposent d'un moyen d'accéder directement à la mémoire contiguë qui est à la fois aussi sûr qu'un Array et aussi performant qu'un pointeur non sécurisé. Il y a quelques années, cette combinaison semblait impossible. Elle est aujourd'hui réalité grâce aux types non-échappables et aux dépendances de durée de vie.

L'impact pratique est considérable. L'étude de cas WWDC25 l'a bien montré : l'adoption de Span, InlineArray et OutputSpan peut transformer un code de parsing lent et gourmand en allocations en un code qui ne réalise qu'une poignée d'allocations et s'exécute des centaines de fois plus vite. Le tout sans sacrifier la sécurité mémoire.

Pour ceux qui travaillent sur des projets mixtes Swift/C/C++, les annotations de bornes et de durée de vie offrent un chemin clair pour sécuriser les frontières entre les langages. Et le mode de sécurité mémoire stricte fournit un filet de sécurité supplémentaire pour les projets les plus sensibles.

Span est appelé à devenir un pilier central de l'écosystème Swift. Si vous n'avez pas encore commencé à l'explorer dans vos projets, c'est le bon moment. Commencez par les hotspots identifiés par Instruments, remplacez progressivement vos pointeurs non sécurisés, et profitez d'un code plus sûr, plus rapide et plus facile à maintenir.