Swiftly

Le monde des Kata de programmation: L’exemple du jeu de la vie en iOS (une approche fonctionnelle)- Partie 1

Dans la culture japonaise, les parents partageaient leur connaissances et savoir faire avec leur enfants. L’enfant commence à apprendre ce qu’on appelle des Kata (synonyme de « forme » ou « cadre » en français). On peut penser qu’un enfant d’un menuisier apprendra les Kata du métier du menuisier, et un enfant d’un pianiste apprendra les cadres de l’art pratiqué par ses parents. Un Kata est généralement un jeu dans lequel on passera des connaissances que les enfants s’apprenaient du monde de travail des adultes. En effet, les Kata sont très connus dans le monde des arts martiaux (Karaté, Judo..). Ce concept a attiré l’attention des informaticiens ensuite surtout avec la monté de la méthode agile d’extrême programming (XP) de Kent Beck, dont je suis très enthousiaste :).

Le concept des Kata a été présenté pour le monde de développement informatique par Dave Thomas dans son livre « The pragmatic programmer« . Beaucoup de piliers du craft logiciels comme oncle Bob dans son livre « The clean coder » ou principalement dans son article « The programming Dojo« , Sandro Mancuso dans son livre « The software Craftsman« , et d’autres…, encourageaient les développeurs à pratiquer les kata d’une manière continue afin de perfectionner leur compétences.

Il existe plusieurs Kata, la plus connu que j’enseignais à mes étudiants aux lycées et à l’université c’était les nombres romains. Malheureusement, J’enseignais les problèmes sans savoir qu’ils sont des katas et aussi sans approche TDD, sans pair programming. Bref, l’idée est de résoudre le problème avec une approche impérative sans tests unitaires. Malheureusement, cela ne permettra pas au développeur (étudiant, enseignant) de comprendre l’utilité du problème et de penser aux différents cas de tests possibles.

Ils existent d’autres Kata, comme le célèbre Fizz Buzz, Gilded Rose et plein d’autres. Le site codingDojo, présente une liste assez intéressante de Kata que je vous laisse le temps de découvrir.

On s’intéressera à un kata un peu particulier vu qu’il provient du monde des mathématiques et principalement de la calculabilité et la décidabilité à savoir le jeu de la vie.

Dans cet article, on présentera le jeu de la vie, l’histoire, l’origine et les règles. Ensuite, on commencera le développement en utilisant une approche fonctionnelle basé sur la TDD.

Bonne lecture…

Le jeu de la vie, l’origine et les règles

Le jeu de la vie ou Game Of Life est un automate cellulaire inventé par le mathématicien anglais John Conway en 1970. Le jeu se joue sur un échiquier infinie dont chaque cellule peut avoir deux états (vivant ou mort). On peut jouer tout seul en partant d’un état initial quelconque. Les règles sont très simples et on peut les résumer comme suit:

  • Une cellule morte peut être vivante lorsqu’elle possède exactement 3 cellules voisines vivantes.
  • Une cellule vivante peut devenir morte lorsqu’elle possède moins que deux cellules voisines vivantes (on parle d’un phénomène d’isolement ou underpopulation) ou plus de 3 cellules voisines vivantes(on parle de surpopulation)

Le plus surprenant au sens mathématiques que Conway a pu dégager à partir de certains états initiaux et après certaines générations des formes stables que Conway les appels des natures mortes (serpent, oscillateur, pendule…). Le net est riche de plusieurs articles mathématiques qui cherchent à expliquer et comprendre comment on arrive à partir de ces deux règles simples à avoir ces formes.

Commençons le Kata

Essayons maintenant de penser une solution qui permet de résoudre d’une manière fonctionnelle et Swifty cet exercice.

On peut commencer par un premier test qui permet de vérifier pour une grille de jeu composée d’une cellule morte, on peut pas trouver des voisins vivants.

On peut imaginer un test de ce genre:


final class GameOfLifeTests: XCTestCase {

    func testNumberOfLivingNeighboursShouldBeZeroInEmptyGameGrid() {
        let game = Game()
        let cell = Cell(x: 2, y: 2)
        XCTAssertEqual(game.livingNeighboursForCell(cell), 0)
    }

}

Nous pensons simplement à deux structures de données un jeu qu’on appelle Game et une cellule possédant deux coordonnées x et y. Le jeu doit être capable pour une cellule bien particulière, de calculer le nombre de ces voisins vivants

Commençons par la création de la classe ou la structure Game

final class Game {
    var cells: [Cell]

init() {
        cells = [Cell]()
    }
}

Puis la structure ou la classe Cell

final class Cell {

    let x: Int
    let y: Int
    
    init(x: Int, y: Int) {
        self.x = x
        self.y = y
    }
}

Pensons maintenant à implémenter une fonction ou une closure qui permet de calculer le nombre de cellules voisines vivantes par rapport à une cellule bien particulière. Pour répondre à cette question, on doit tout d’abord connaitre quels sont les cellules voisines?

ll y a cinq ans, on m’a proposé ce Kata comme test et je me suis bloqué sur la détermination des cellules voisines qui pour moi était la question la plus difficile 😃. Vu que nous sommes pas dans un entretien, essayons de penser calmement et trouver une bonne formule qui permet de déterminer les voisins.

On peut raisonner sur les indices des cellules et on remarquera que la différence en valeur absolue entre une cellule et ces voisins peut se résumer en ces trois cas au max (1,1),(1,0),(0,1). Comment on a aboutit à ça, regardons cette grille

En effet, si nous prenons par exemple, une cellule au milieu, la delta de différence entre sa position x et la position x de ses voisins ne dépassera pas 1 en valeur absolue et pareil pour sa position y. En effet, la cellule en haut à gauche et à une ligne de moins par rapport à notre cellule de milieu et une colonne de moins par rapport à notre cellule de milieu. Alors, on a cet offset de (-1,-1). On peut penser à une fonction qui cherchera les voisins en se basant sur cette logique et on aura:

private let cellsAreNeighbours = {
  (rhs:Cell, left: Cell) -> Bool in
  let delta = (left.x - rhs.x,left.y - rhs.y)
  switch delta {
  case (-1,-1),(-1,0),(-1,1),(0,-1),(0,1),(1,-1),(1,0),(1,1): return true
  default: return false
  }
}

Utilisons cette closure pour chercher les voisins d’une cellule quelconque

lazy var neighboursForCell = { (cell: Cell) -> [Cell] in
   self.cells.filter{ self.cellsAreNeighbours(cell, $0) }
}

Vous remarquez que l’écriture fonctionnelle rend notre code plus lisible vu qu’on utilise la fonction filtrer sur la séquence (tableau cells) afin de déterminer seulement les voisins. Autre chose, l’utilisation de la position x et y, nous évitera de créer une matrice et faire un parcours avec deux boucles. Alors notre parcours est toujours à O(n) en terme de complexité algorithmique temporelle.

Lançons notre premier test.

Passons à un deuxième test maintenant.

L’idée de ce deuxième test, c’est de vérifier pour une cellule bien particulière, qu’elle doit contenir après l’initialisation de jeu, un voisin vivant.

func testFoundOneLivingNeighbours() {
  let game = Game()
  let cell = Cell(x: 2, y: 2)
        
  game[2,1]?.state = .Alive
  XCTAssertEqual(game.livingNeighboursForCell(cell), 1)
}

Pour ce cas, la cellule (2,1) est voisine de la cellule (2,2). Comment peut on ajouter pour une cellule un état (state) et comment je peux accéder pour initialiser la cellule (2,1)?

Ecrivons le code qui permet d’ajouter l’attribut state à une cellule.

enum State {
    case Alive
    case Dead
}

final class Cell {

    let x: Int
    let y: Int
    var state: State
    
    init(x: Int, y: Int) {
        self.x = x
        self.y = y
    }
}

On a ajouté un type énuméré State qui contient 2 valeurs possibles:

  • Vivant: Alive
  • Mort: Dead

Surement, vous avez remarqué que je peux accéder à une cellule [x,y] en utilisant l’objet game et non pas faire game.cells[2,1]. En effet, j’utilise un subscript sur Game qui me permet d’accéder à une cellule de mon jeu directement.

subscript (x: Int, y: Int) -> Cell? {
   return cells.filter { $0.x == x && $0.y == y }.first
}

Les subscripts sont des raccourcis pour accéder au membres d’une classe, structure ou type énumère. On peut définir plus qu’un subscript sur un type. Dans notre example, le subscript permet de filtrer sur les cellules du Game et retourner une cellule dans les coordonnées sont égales à ceux passés en paramètres. Le résultat peut être nil, c’est pour cela on a ce type de retour Cell?.

Lançons notre deuxième test maintenant.

Dans le processus de développement TDD, il y a une étape très importante, c’est le refactoring (red -> Green -> refactor). Ce que nous pouvons remarqué dans la propriété cellsAreNeighbours qu’on peut améliorer la code en raisonnant en valeur absolue et on peut tomber sur 3 cas au lieu des 8 cas possibles

private let cellsAreNeighbours = {
   (rhs:Cell, left: Cell) -> Bool in
   let delta = (abs(left.x - rhs.x),abs(left.y - rhs.y))
   switch delta {
   case (1,1),(1,0),(0,1): return true
     default: return false
   }
}

Relancez les tests, normalement rien n’est cassé 🙂

On peut sécuriser notre code par l’ajout d’autres tests qui calcule le nombre de voisins pour une cellule s’il est égale à 2, 3…

func testFoundTwoLivingNeighbours() {

   let game = Game()

   let cell = Cell(x: 2, y: 2)

   game[2,1]?.state = .Alive

   game[2,3]?.state = .Alive

   XCTAssertEqual(game.livingNeighboursForCell(cell), 2)

}

Passons maintenant à la deuxième partie de notre Kata. La question qui se pose, comment je peux déterminer l’état de mon jeu pour la génération suivante.Il faut penser à implémenter le code qui permet de déterminer l’état de chaque cellule de jeu pour la génération suivante. Comme d’habitude, pensons au tests.

L’idée de notre test, si j’ai une cellule morte et qui est entourée de 3 cellules voisines vivantes, notre cellule deviendra vivante.

Notre test aura cette allure:

func testADeadCellWithThreeNeighboursGetsAlive() {
   let game = Game()
        
   game[0,3]?.state = .Alive
   game[0,4]?.state = .Alive
   game[0,5]?.state = .Alive
   game[1,4]?.state = .Dead
        
   game.computeNextGeneration()
        
   XCTAssertEqual(game[1,4]?.state, .Alive)
}

On est dans l’étape red en TDD, on doit penser à implémenter cette fonction computeNextGeneration().

On va s’intéresser tout d’abord à déterminer les cellules mortes, chercher ceux qui ont des voisins vivants puis appliquer une fonction de transformation pour avoir une nouvelle matrice de cells.

func computeNextGeneration() {
  let deadCells = cells.filter { $0.state == .Dead }

  let newLifeCells = deadCells.filter { livingNeighboursForCell($0) == 3 }
  // update state
  newLifeCells.forEach{ cell in
    cell.state = .Alive
  }
}

Vous remarquez que je change dans le tableau newLifeCells qui est une référence du tableau cells vu que notre Cell est une classe (chaque modification dans le newLifeCells impactera le tableau d’origine cells)

Lançons maintenant notre test.

Passons au test sur les cellules vivantes qui deviennent mortes suite à l’isolement ou la surpopulation.

Commençons par la surpopulation.

func func testACellWithMoreThanThreeLivedNeighboursDies() {
   let game = Game()
        
   game[0,3]?.state = .Alive
   game[0,4]?.state = .Alive
   game[0,5]?.state = .Alive
   game[1,3]?.state = .Alive
   game[1,4]?.state = .Alive
        
   game.computeNextGeneration()
        
   XCTAssertEqual(game[1,4]?.state, .Dead)
 }

La cellule (1,4) vivante deviendra morte dans la génération suivante. Ajoutons le code nécessaire pour faire cette transformation au niveau de la méthode computeNextGeneration()

func computeNextGeneration() {
.....
let liveCells = cells.filter{ $0.state == .Alive }
let dyingCells = liveCells.filter { livingNeighboursForCell($0) > 3 }

dyingCells.forEach{ cell in
    cell.state = .Dead
}

lançons notre test

Passons au test du cas d’isolement

func testACellWithLessThanTwoLivedNeighboursDies() {
   let game = Game()
        
   game[0,3]?.state = .Alive
   game[1,4]?.state = .Alive
        
   game.computeNextGeneration()
        
   XCTAssertEqual(game[1,4]?.state, .Dead)
}

Il suffit d’ajouter cette condition au niveau du filtre des cellules qui vont être mortes.

let dyingCells = liveCells.filter { livingNeighboursForCell($0) > 3 || livingNeighboursForCell($0) < 2 }

Conclusion

Le jeu de la vie constitue un Kata simple et intéressante pour perfectionner notre manière d’appréhender des problèmes algorithmiques et éventuellement se préparer pour des entretiens d’embauche. Dans cette première partie, nous avons présenté comment l’utilisation du TDD pour écrire petit à petit un code testable à 100% peut être si facile. Dans la partie suivante, nous allons s’intéresser à relier la logique algorithmique avec la création UI en utilisant UIKIT, et comment on peut améliorer notre solution en terme de complexité algorithmique.

A très bientôt.

Références:

Extreme programming explained: Kent Beck.

Software craftsmanship: Sandro Mancuso.

ARTE: Voyages au pays des maths: Le jeu de la vie

Le kata et le jeu : l’éducation par le jeu dans une « société sans cadre » ?

Démystifier des problèmes du test iOS CodinGame (approche TDD)

C’est quoi le CodinGame?

Bonjour les développeurs,

Durant le processus de recrutement, plusieurs entreprises d’IT (généralement des ESN, grandes entreprises de développement) vous envoient comme première étape du processus de recrutement le CodinGame. Si vous êtes familiarisé avec des sites de genre HackerRank, LeetCode, Codeforces…, le test CodinGame ne sera pas difficile normalement pour vous :).

Nous ne sommes pas ici pour juger le test en matière de questions posées. L’idée consiste à faire connaître ce test aux développeurs qui ne le connaissent pas et d’essayer par la suite de simplifier la compréhension des exercices de type « problème algorithmique » qui ont un poids énorme au niveau de l’évaluation finale attribuée.

Dans cet article, nous allons essayer de résoudre en Swift, deux problèmes de ce test en suivant l’approche TDD.

Problème: Trouver le noeud terminal

L’objectif de ce problème est de trouver le noeud terminal d’un réseau simple.

Dans ce réseau:

  • Chaque nœud a seulement un successeur.
  • Le dernier nœud ne possède aucun successeur.
  • Le réseau est unidirectionnel.

Dans l’exemple c-dessus, le noeud terminal en partant par exemple du noeud 8 est le 7.

Dans l’exercice, on nous demande d’implémenter une méthode qui renvoie le dernier nœud de ce réseau à partir d’un nœud initial startNodeId en suivant les liens unidirectionnels du réseau.

findNetworkEndPoint(startNodeId, fromIds, toIds) -> Int

Notre réseau est unidirectionnel, chaque élément du tableau formIds doit avoir un successeur dans le tableau toIds, cela se traduit par la relation fromIds[i]toIds[i]. De ce fait, ces deux tableaux doivent avoir la même longueur.

Selon le schéma ci-dessus le contenu des deux tableaux est le suivant:

let fromIds = [2,5,3,8,14,10]
let toIds = [3,3,14,10,7,7]

En suivant l’approche TDD (Test-driven development), nous commençons par écrire nos tests puis nous développons notre algorithme avec un minimum de codes pour réussir nos tests.

Côté CodinGame, on suppose que la saisie est correcte. Alors, nous ne pouvons pas tomber sur un cas où nous cherchons un élément qui n’existe pas dans le tableau fromIds.

Tout d’abord, nous voulons commencer par tester ces deux cas,

  • Les deux tableaux fromIds et toIds sont vides.
  • Le tableau fromIds contient un seul élément et le tableau toIds est vide.

Voici à quoi resemble notre playground dans un premier lieu :

import UIKit
import XCTest

final class SimpleNetworkTests: XCTestCase {
    override func setUp() {
        super.setUp()
    }
    
    override func tearDown() {
        super.tearDown()
    }
    
    func testEmptyToIdsArrayShouldReturnStartNode() {
        // Given
        let fromIds = [2]
        let toIds = [Int]()
        let sut = SimpleNetwork()

        // THEN
        XCTAssertEqual(
            sut.findNetworkEndPoint(
                2,
                fromIds,
                toIds
            ),
            2
        )
        
    }
    
    func testEmptyFromIdsAndToIdsArraysShouldReturnStartNode() {
        // Given
        let fromIds = [Int]()
        let toIds = [Int]()
        let sut = SimpleNetwork()

        // THEN
        XCTAssertEqual(
            sut.findNetworkEndPoint(
                2,
                fromIds,
                toIds
            ),
            2
        )
        
    }
    
}

final class SimpleNetwork {
    
    init() {}
    
    func findNetworkEndPoint(
        _ startNodeId: Int,
        _ fromIds: [Int],
        _ toIds: [Int]
    ) -> Int {
        return startNodeId
    }
}

SimpleNetworkTests.defaultTestSuite.run()

Si nous réalisons les deux tests, les tests passeront, étant donné que notre algorithme renvoie toujours startNodeId.

Examinons maintenant le cas où nous avons deux tableaux fromIds et toIds qui ne sont pas vides, écrivons notre test.

func testNoEmptyFromIdsAndToIdsArraysShouldReturnStartNode() {
        // Given
        let fromIds = [2,5,3,8,14,10]
        let toIds = [3,3,14,10,7,7]
        let sut = SimpleNetwork()

        // THEN
        XCTAssertEqual(
            sut.findNetworkEndPoint(
                2,
                fromIds,
                toIds
            ),
            7
        )
        
    }

En partant du noeud 2, on arrivera au noeud 7. Ce test ne passera pas vu que notre code de production ne gère pas encore ce cas.

Sur Playground, vous aurez sur votre console cet affichage.

Ajoutons le stricte minimum du code pour passer ce test.

func findNetworkEndPoint(
        _ startNodeId: Int,
        _ fromIds: [Int],
        _ toIds: [Int]
    ) -> Int {
        var copyStartNodeId = startNodeId
        while(fromIds.contains(copyStartNodeId)) {
            let startIdToIdIndex = fromIds.firstIndex(of: copyStartNodeId) ?? 0
            copyStartNodeId = toIds[startIdToIdIndex]
        }
        return copyStartNodeId
    }

L’idée est de déterminer ou se trouve le startNodeId dans le tableau fromIds, ensuite d’écraser le startNodeId par son successeur au niveau du tableau toIds.

Lançons les test maintenant 🙂 , on remarque que notre premier test est impacté pour ce nouveau code

Rappelons que dans notre premier test, on a le tableau fromIds qui contient seulement 2 et que le tableau toIds est vide. Vu que la première instruction dans notre code est fromIds.contains(copyStartNodeId), on entrera dans l’itération sauf que l’erreur provient de cette instruction copyStartNodeId = toIds[startIdToIdIndex] ou on cherche le successeur de 2 dans le tableau toIds déjà vide.

La solution simple pour s’assurer, est de vérifier que le tableau toIds n’est pas vide avant de remplacer le copyStartNodeId.

func findNetworkEndPoint(
        _ startNodeId: Int,
        _ fromIds: [Int],
        _ toIds: [Int]
    ) -> Int {
        var copyStartNodeId = startNodeId
        while(fromIds.contains(copyStartNodeId)) {
            if let startIdToIdIndex = fromIds.firstIndex(of: copyStartNodeId),
               toIds.contains(startIdToIdIndex) {
                copyStartNodeId = toIds[startIdToIdIndex]
            }
        }
        return copyStartNodeId
  }

Tout nos tests passent maintenant.

Passons maintenant au deuxième problème que nous avons choisi de te montrer, c’est la recherche d’un élément dans une arbre binaire ordonné ABR.

Problème: Trouver un élément dans une arbre binaire de recherche(ABR)

Un arbre binaire de recherche est composé de noeuds qui respectent les règles suivantes:

  • Un noeud a une valeur de type entier.
  • Un noeud n’a pas plus de deux enfants, appelés noeud droite et noeud gauche.
  • La valeur de tout enfant du sous arbre gauche est inférieur à la valeur de son parent.
  • La valeur de tout enfant du sous arbre droite est supérieur à la valeur de son parent.

La hauteur de l’arbre (la distance entre le noeud le plus éloigné et la racine) est comprise entre 0 et 100000 noeuds.

Le candidat doit développer une méthode find(_ v: Int) qui retourne le noeud tenant la valeur v s’il existe.

On nous propose une classe Node qui contient trois attributs.

class Node {
    var value: Int
    var left: Node?
    var right: Node?
    
    init(_ value: Int) {
        self.value = value
    }
    
    func find(_ v: Int) -> Node? {
        return nil
    }
}

Pareil au problème précédent, on commencera par écrire une classe de test qu’on l’appelle NodeTests

class NodeTests: XCTestCase {
    override func setUp() {
        super.setUp()
    }
    
    override func tearDown() {
        super.tearDown()
    }
}
// Lancement des tests
NodeTests.defaultTestSuite.run()

On commencera par un simple test qui permet de vérifier si la recherche d’un élément non existant dans notre arbre avec un seul élément retournera nil.

func test_Search_No_Existing_Value_ON_MonoTree_Elements_ShouldReturnNil() {
     // GIVEN
     let root = Node(10)
        
     // THEN
     XCTAssertNil(root.find(3))
}

Ce test passera systématiquement vu que notre code de production retournera maintenant nil dans tout les cas 🙂 .

Passons à un deuxième TU.

func test_Search_Existing_Value_ON_MonoTree_Elements_ShouldNotReturnNil() {
    // GIVEN
    let root = Node(10)
        
    // THEN
    XCTAssertNotNil(root.find(10))
}

Ce test ne passera pas (phase rouge du TDD 🧪), essayons de passer ce test avec le minimum du code possible.

func find(_ v: Int) -> Node? {
     if value == v {
        return self
     }
     return nil
 }

Voila nos tests passent ✅

Passant ensuite au cas où notre arbre a un seul enfant droit (Une seule génération)

func test_Search_Existing_Value_ON_Tree_With_One_Right_Child_ShouldNotReturnNil() {
        // GIVEN
        let root = Node(10)

        let rightElementRoot = Node(14)

        root.right = rightElementRoot

        // THEN
        XCTAssertNotNil(root.find(14))
 }

Ce test ne passera pas aussi! vu que le noeud 14 existe et l’algorithme retourne nil

Ici la solution est simple, nous devons revenir a la condition:

💡 La valeur de tout enfant du sous-arbre droite est supérieure à la valeur de son parent.

Ce qui se traduit en code:

func find(_ v: Int) -> Node? {
        
     if value == v {
            return self
     }
        
     if v > value, let rightNode = right {
            return rightNode
     }
}

Nous faisons la même chose avec le côté gauche.

func test_Search_Existing_Value_ON_Tree_With_One_Left_Child_ShouldNotReturnNil() {
     // GIVEN
     let root = Node(10)

     let leftElementRoot = Node(7)

     root.left = leftElementRoot

     // THEN
     XCTAssertNotNil(root.find(7))
 }

💡La valeur de tout enfant du sous-arbre gauche est inférieure à la valeur de son parent.

func find(_ v: Int) -> Node? {
        
    if value == v {
            return self
     }
        
    if v > value, let rightNode = right {
            return rightNode
    }

    if v < value, let leftNode = left {
            return leftNode
    }
        
     return nil
}

Passons à la recherche dans une arbre à plusieurs générations ou niveaux.

func test_Search_Existing_Value_ON_Multi_levels_Tree_ShouldNotReturnNil() {
        // GIVEN
        let root = Node(10)

        let node7 = Node(7)
        let node14 = Node(14)

        root.left = node7
        root.right = node14

        let node4 = Node(4)
        let node9 = Node(9)

        node7.left = node4
        node7.right = node9

        let node1 = Node(1)
        let node5 = Node(5)

        node4.left = node1
        node4.right = node5

        // THEN
	let node = root.find(1)
        XCTAssertNotNil(root.find(1))
	XCTAssertEqual(node?.value, 1)
  }

Pour ce cas, nous allons utiliser la récursivité. On remarque bien que dans la recherche d’un nœud dans tout l’arbre, l’idée consiste à faire la recherche soit dans la génération gauche si la valeur est inférieure au nœud parent, ou la génération droite, si la valeur est supérieure au nœud parent et ainsi de suite jusqu’à nous tombons sur le critère d’arrêt if value == v { return self} ou bien return nil si le noeud n’existe pas.

Cherchant le noeud avec la valeur 1 par exemple:

func find(_ v: Int) -> Node? {
    if value == v {
            return self
    }
        
    if v > value,
       let rightNode = right {
            return rightNode.find(v)
    }

    if v < value,
       let leftNode = left {
            return leftNode.find(v)
    }
        
    return nil
  }

Techniques modernes de Hot Reloading en iOS/Mac OS

Introduction

Le Hot Reloading ou ‘le rechargement à chaud’ désigne l’affichage en temps réel des modifications UI sans devoir relancer l’application informatique. Cela, permet de gagner énormément en terme de productivité vu qu’on aperçoit en temps réel les effets de notre changement sans recompiler toute l’application.

Le Hot Reloading est connu au niveau des technologies mobiles hybride comme par exemple React Native, Flutter… étant donné que les langages derrière comme JS, Type Script ou Dart sont des langages interprétables. Le Hot Reloading est un peu méconnu dans les langages compilés tel que Swift, Objective-C, Kotlin… qui doivent passer par tout le cycle de compilation et d’édition de liens pour générer l’artefact final.

Dans cette article, j’essayerai d’explorer les techniques actuels de Hot reloading en iOS native. On explorera à travers des projets démos en iOS et Mac OS des frameworks comme InjectionIII et Inject.

Historique des solutions natives

Il n’y a pas de solutions natives offerte par Apple pour réaliser le hot reloading. Si nous comparons l’effort fait par Google à travers Instant run dite maintenant Live Edit, on peut dire que Xcode n’offre rien en ce sujet.

Cependant, la communauté a pu créer plusieurs frameworks dont l’idée de réaliser ce challenge.

  • Dynamic code injection(DyCI): un outil à base d’Objective-C crée par Paul Taykalo.
  • Vaccine: une framework développé par Christoffer Winterkvist et Richard Topchii
  • Inject: un outil crée par Krzysztof Zabłocki.

Toutes ces solutions passent par un logiciel qui permet d’injecter en temps réel les changements du code au niveau de l’application et communiquer avec le système d’exploitation pour réaliser ce challenge. Cette application est InjectorIII Injection for Xcode (InjectionIII): un outil crée par John Holdsworth.

Dans la suite de cette article, on va essayer de prendre un exemple d’application Mac OS à base de SwiftUI et comment on peut utiliser Inject et InjectorIII pour faire du live changes sur notre code.

Installation et configuration de InjectionIII et Inject

  1. Installation de InjectionIII:

Pour pouvoir installer InjectionIII, vous pouvez installer depuis App Store ou depuis GitHub

Une fois installé (l’application est de type menu bar application), vous allez trouvé bien dans l’écran principal

InjectionIII

Vous devez ensuite sélectionner votre projet sur lequel InjectionIII va s’intégrer, pour notre cas ça sera un projet Mac OS, une application que j’ai appelé Reminders pour gérer les notes.

Pour pouvoir, vous cliquez sur l’icône d’InjectionIII ensuite Add directory et vous sélectionnez le dossier qui contient le fichier .xcodeproj

Ajouter un projet native à InjectionIII

Lorsque vous lancez votre application, vous devez avoir un message de connection de injectionIII avec votre application Mac OS

Connection de l’application Reminders avec InjectionIII

Vous remarquez aussi que la couleur de l’icône de InjectionIII a changé vers l’orange ce qui signifie que la liaison est bien faite avec l’application. Mais pour réaliser cette liaison, il faut passer par des étapes avant qu’on va découvrir ensemble.

Configuration d’un fichier AppDelegate.swift

On peut créer un fichier AppDelegate.swift et le lier ensuite avec le fichier de chargement principal de mon application Reminders

class AppDelegate: NSObject, NSApplicationDelegate {
    func applicationDidFinishLaunching(_ notification: Notification) {
        setupInjector()
    }
    
    private func setupInjector() {
        Bundle(path: "/Applications/InjectionIII.app/Contents/Resources/macOSInjection.bundle")?.load()
    }
}

le code ne sera exécuté que si une instance de AppDelegate sera appelé dans le fichier RemindersApp.swift

@main
struct RemindersApp: App {
    @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
}

La fonction load permet au système d’exploitation de charger une application externe genre InjectionIII au moment de l’exécution de l’application en cours. Dans notre cas, on va charger le bundle injectionIII Mac OS qui existe au niveau de ma machine à cette endroit /Applications/InjectionIII.app/Contents/Resources/macOSInjection.bundle

Vous pouvez explorer les différents types de bundles supportés, juste en accédant au dossier d’installation de InjectionIII ensuite l’ouvrir (clique droit -> ouvrir paquet )

InjectionIII bundles types

Pour pouvoir créer et lancer automatiquement l’appDelegate, on utilise une propriété wrapper de type @NSApplicationDelegateAdaptor. Vous pouvez consulter la documentation officielle sur ce sujet.

Mais ce n’est pas suffisant pour réaliser la liaison, il faut désactiver certaines capabilities au niveau du fichier entitlement de l’application qui sont:

com.apple.security.app-sandbox et com.apple.security.cs.disable-library-validation

<dict>
	<key>com.apple.security.app-sandbox</key>
	<false/>
	<key>com.apple.security.files.user-selected.read-only</key>
	<true/>
        <key>com.apple.security.cs.disable-library-validation</key>
        <false/>
</dict>

Dernier truc important au niveau de la configuration de InjectionIII avec le projet, il est nécessaire d’ajouter les flags -Xlinker et interposable afin de demander au compilateur d’authoriser l’action de recompilation d’un fichier qui existe en mémoire centrale et de remplacer en runtime celui qui est déjà en exécution sinon vous aurez ce message.

Vous ajoutez les deux au niveau du Build settings -> Other linker Flags

-XLinker et -interposable flags

Vu que notre projet est basé sur SwiftUI et non pas AppKit, il faut essayer de recharger l’interface swiftUI à chaque fois qu’il y un changement dans l’interface SwiftUI. A ce moment , on aura besoin de Inject la bibliothèque qui intercepte les changements et demande à InjectionIII de recompiler et recharger l’application.

  1. Installation de Inject

Inject est une framework open source à base Swift qu’on peut installer à travers CocoaPods ou SPM.

Inject via SPM

Il suffit ensuite d’importer au niveau de votre vue SwiftUI puis de déclarer un type par exemple dans notre cas un InjectionObserver qui notifiera InjectionIII à travers une notification spéciale connue par InjectionIII, le INJECTION_BUNDLE_NOTIFICATION, pour recompiler la vue en cours

public let injectionObserver = InjectionObserver()

public class InjectionObserver: ObservableObject {
    @Published var injectionNumber = 0
    var cancellable: AnyCancellable?
    let publisher = PassthroughSubject<Void, Never>()
    init() {
        cancellable = NotificationCenter.default.publisher(for:
            Notification.Name("INJECTION_BUNDLE_NOTIFICATION"))
            .sink { [weak self] _ in
            self?.injectionNumber += 1
            self?.publisher.send()
        }
    }
}

Appel de InjectionObserver dans une vue swiftUI

struct MyView: View {    
#if DEBUG
    @ObservedObject var iO = injectionObserver
#endif

Maintenant, on peut modifier dans notre code et vous allez remarquez les changements en temps réel.

Dans notre exemple, on a un fichier SideBarView.swift qui contient un text, une liste et un button en bas, on vas essayer de mettre en commentaire le code du Button de cette manière. On peut voir sans recompiler que le button se cache.

var body: some View {
        VStack(alignment: .leading) {
            Text("My list")
            MyListsView(viewModel: MyListsViewModel(context: context))
            Spacer()
            Button {
                isPresented = true
            }label: {
                HStack {
                    Image(systemName: Constants.Icons.plusCircle)
                    Text("Add List")
                }
            }.buttonStyle(.plain)
                .padding()
        }.sheet(isPresented: $isPresented) {
            // dismiss
        } content: {
            AddNewListView(viewModel: AddNewListViewModel(context: context))
        }.enableInjection()
    }
SideBar avant la modification

InjectionIII recompile et recharge la vue 🙂

Recompilation du fichier SideBarView.swift

Limites et contraintes

Lorsque j’ai essayé d’utiliser injectionIII et Inject sur un grand projet avec plusieurs frameworks et packages j’ai eu des problèmes à l’exploiter vu que le changement dans les couches inférieurs n’impactent pas instantanément la partie UI. J’étais obliger de faire un aller retour par exemple pour voir ma liste de produits se rafraîchir. En autre, j’ai pas pu l’exploiter sur un device apparemment et je suis pas le seul.

A très bientôt pour un nouveau sujet 🙂

Création d’un EDSL avec ResultBuilder (une approche déclarative en Swift)

Durant des années, j’ai confondu langage de programmation et DSL (Domain Specific Language).

Ne pensant pas que HTML pouvait être un DSL tout comme SQLGHERKIN  et bien d’autres.

Dans l’écosystème d’Apple, on utilise très souvent les DSL sans les connaitre, par exemple, le fichier podfile de CocoaPods ou même SwiftUI.

En effet, l’objectif de cet article est de simplifier le concept de DSL en présentant un exemple pratique qui nous permet d’écrire à travers des fonctions dites « builder » un DSL permettant d’appliquer des mises en forme sur des chaines de caractères.

Pour simplifier, on veut écrire un DSL qui permet d’écrire un code déclaratif plutôt que l’approche impérative que l’on implémente couramment lors de la mise en forme d’une chaine de caractères.

Chronologiquement, on va essayer de présenter:

  • Qu’est ce qu’un DSL ou langage dédié (traduction Wikipedia)?
  • Qu’est ce qu’un ResultBuilder?
  • Comment on peut utiliser en pratique un ResultBuilder pour créer un DSL qui permet de simplifier l’écriture des contraintes (NSAttributedConstraint)?

Qu’est ce qu’un DSL?

Un DSL est un langage dédié à un domaine particulier. Par exemple, SQL est un DSL pour interagir avec une base de données relationnelles. Si vous utilisez SPM, CocoaPods ou Carthage vous utilisez un DSL pour installer vos frameworks. Par exemple, le fichier podfile est un fichier en Ruby et toutes les commandes qu’on écrit genre pod « nom de framework », ~> 2.0 respecte un syntaxe bien spécifique qui va faire appel à du code en Ruby.

// Cocoapods
platform :ios, '15.0'
use_frameworks!

target 'MyApp' do
  pod 'Utils', '~> 0.2'
  pod 'rxswift'
end

Sans cette écriture assez déclarative, on sera dans l’obligation d’écrire de pure Ruby pour pouvoir installer ces pods.

De la même manière pour SPM, le fichier package.swift est écrit dans un langage spécifique au domaine d’installation des frameworks mais à la base c’est du langage Swift.

// swift-tools-version:5.1
import PackageDescription

let package = Package(
    name: "MyLibrary",
    platforms: [
        .macOS(.v10_15),
    ],
    products: [
        .library(name: "MyLibrary", targets: ["MyLibrary"]),
    ],
    dependencies: [
        .package(url: "https://url/of/another/package/named/Utility", from: "1.0.0"),
    ],
    targets: [
        .target(name: "MyLibrary", dependencies: ["Utility"]),
        .testTarget(name: "MyLibraryTests", dependencies: ["MyLibrary"]),
    ]
)

Cette manière d’écrire du code déclaratif est assez répondu dans les DSL que j’ai vu. Cela est du à mon avis que l’écriture déclarative est plus simple et élégante que celle qu’on écrit généralement en pensant d’une façon impérative.

Dans son livre « Domain-Specific Languages« , Martin Fowler distingue entre deux types de DSL:

  • Les DSL internes qu’on appelle généralement Embeded Domain specific Language.
  • Les DSL externes

EDSL utilise généralement un autre langage pour mettre ses règles et son syntaxe qui est à la base du langage accueillant. Par exemple, SPM utilise Swift pour écrire le fichier package.swift alors il respecte le syntaxe de Swift.

Par contre SQL est un DSL externe qui possède sa syntaxe à lui et qui n’est pas lié à un langage existant.

ResultBuilder:

ResultBuilder est une évolution qui arrivait en Swift 5.4. C’est une évolution pourquoi? car son ancêtre est les functions builder apparus avec Swift 5.1. vous pouvez suivre la proposition faite par John McCallDoug Gregor ici SE-0289. En effet, ResultBuilder permet de créer une nouvelle valeur à la base d’une séquence entré en paramètre. Dans notre cas, nous voulons retourner un NSAttributedString qui constitue la combinaison de plusieurs NSAttributedString (un nombre qu’on maitrise pas). Pour ce faire, on peut créer un nouveau type qu’on appellera par exemple

Comment peut-on utiliser en pratique un ResultBuilder pour créer un DSL simplifiant l’écriture des contraintes (NSAttributedConstraint)

L’utilité de notre EDSL sera d’écrire d’une manière déclarative très proche de SwiftUI une fonction permettant de mettre en forme une liste de chaine de caractères.

On veut par exemple afficher ce texte:

On fera un appel à une fonction qu’on peut appeler welcomeBuilder avec ses paramètres :

welcomeBuilder(name: "Walid",titles: ["Tunisian", "iOS Developer", "work at Sephora"])

Ce qui nous intéresse comment on veut écrire cette fonction de la manière la plus déclarative possible. Imaginons par exemple que notre code à la fin sera écrit de cette manière 🙂 :

func welcomeBuilder(name: String, titles: [String]) -> NSAttributedString {
    if !titles.isEmpty && !name.isEmpty {
        Text("Hello ")
        Text(name).color(.red)
        for title in titles {
            SpecialCharacters.comma
            SpecialCharacters.lineBreak
            Text(title)
                .font(.systemFont(ofSize: 20))
                .color(.black)
        }
    } else {
        Text("No Title")
    }
}

Vous remarquez surement qu’on est très proche de SwiftUI. Si par exemple, nous écrivons le code d’une manière habituel, on aura un code très proche de celui la:

func welcome(name: String, titles: [String]) -> NSAttributedString {
    let message = NSMutableAttributedString()
    if !titles.isEmpty && !name.isEmpty {
        let attributes = [NSAttributedString.Key.backgroundColor :
                            UIColor.red]
        message.append(NSAttributedString(string: "Hello "))
        message.append(NSAttributedString(string: name, attributes:
                                            attributes))
        for title in titles {
            message.append(NSAttributedString(string: ", "))
            message.append(NSAttributedString(string: "\n"))
            let attributes2 = [
                NSAttributedString.Key.font : UIFont.systemFont(ofSize: 20),
                NSAttributedString.Key.backgroundColor : UIColor.black
            ]
            message.append(NSAttributedString(string: title, attributes: attributes2))
        }
        
        return message
    } else {
        message.append(NSAttributedString(string: "No Title"))
        return message
    }
}

Vous remarquez surement la différence entre les deux méthodes et le nombre de lignes de code qu’on peut gagner.

Pour réaliser la création du DSL relatif à la première méthode, on peut utiliser un nouveau type de fonctions qui sont les result Builder.

@resultBuilder
struct AttributedStringBuilder { ... }

On ajoute l’annotation @resultBuilder avant le nom du nouveau type (auparavant on utilisait @_functionBuilder).

En effet, l’ajout de l’annotation nous ramène à implémenter obligatoirement une méthode static buildBlock

static buildBlock

Cette méthode permet de construire le résultat final produit par le nouveau type AttributedStringBuilder qui sera normalement de type NSAttributedString. En effet, la méthode prendra en paramètre un nombre inconnu de NSAttributedString. L’idée ensuite, est de nous permettre de passer une séquence de NSAttributedString à cette méthode afin de nous retourner le résultat finale attendu.

La méthode utilise un variadic paramètre pour gérer le nombre inconnu de paramètres qu’on peut envoyer à notre builder.

static func buildBlock(_ components: NSAttributedString...) -> NSAttributedString {
  let resultAttributedString = NSMutableAttributedString()
    for component in components {
        resultAttributedString.append(component)
    }
  return resultAttributedString
}

Vous remarquez que la logique faite par le buildBlock est très simple, on essayera de « append » à chaque fois un composant de type NSAttributedString à notre resultAttributedString.

On peut exploiter notre AttributedStringBuilder de cette manière. En effet, la méthode welcomeBuilder ajoute à chaque fois un NSAttributedString relatif à un texte. N’oubliez pas d’ajouter une nouvelle annotation relative à notre nouvelle type AttributedStringBuilder. Vous remarquez la magie derrière qui permet au compilateur de transférer nos Text(« Hello ») et Text(name) vers la méthode buildBlock afin de construire le résultat final.

typealias Text = NSMutableAttributedString
extension NSMutableAttributedString {
  convenience init(_ string: String) {
    self.init(string: string)
  }
}
@AttributedStringBuilder
func welcomeBuilder(name: String, titles: [String]) -> NSAttributedString {
    Text("Hello ")
    Text(name)
}

Imaginons maintenant qu’on veut appliquer une mise en forme spécifique à un Text (changement de couleur de text ou de font par exemple). On peut le faire simplement en ajoutant une petite extension sur NSAttributedString.

extension NSMutableAttributedString {
    .....
    func color(_ color: UIColor) -> NSMutableAttributedString {
        self.addAttribute(
            .backgroundColor,
            value: color,
            range: NSRange(location: 0, length: self.length)
        )
        return self
    }
}

Ce changement n’affectera pas notre EDSL et comment notre ResultBuilder transforme ces Text vers le NSAttributedString final. On écrira par exemple:

func welcomeBuilder(name: String, titles: [String]) -> NSAttributedString {
    Text("Hello ")
    Text(name).color(.red)
}

Poussons notre ResultBuilder DSL

Imaginons maintenant qu’on veut afficher un message d’erreur à la place de Hello ‘name’. On est dans l’obligation d’ajouter une condition pareil à celle la:

@AttributedStringBuilder
func welcomeBuilder(name: String, titles: [String]) -> NSAttributedString {
    if !name.isEmpty {
        Text("Hello ")
        Text(name).color(.red)
    } else {
        Text("No Title")
    }
}

Cependant notre ResultBuilder aura des problèmes à interpréter ce code Swift, vu qu’il ne connait pas comment il doit interagir avec les conditions et quel résultat il doit retourner.

ResultBuilder control flow error

La solution est d’implémenter deux méthodes statics dans notre AttributedStringBuilder qui sont :

static func buildEither(first component: NSAttributedString) -> NSAttributedString {
   return component
}

static func buildEither(second component: NSAttributedString) -> NSAttributedString {
   return component
}

La première méthode permet de gérer la première condition de vérité en retournant le composant tel qu’il est. Concernant la deuxième méthode, elle permet de gérer le cas de else.

Imaginons que l’on veuille appliquer  une mise en forme sur les deux components texts lorsque la condition est vrai. On peut à ce moment écrire notre méthode buildEither(first component) de cette manière :

static func buildEither(first component: NSAttributedString) -> NSAttributedString {
    var resultAttributedString = NSMutableAttributedString()
    resultAttributedString = component as! NSMutableAttributedString
    resultAttributedString.addAttribute(
          .foregroundColor,
          value: UIColor.green,
          range: NSRange(location: 0, length: component.length)
     )
    return resultAttributedString
}

Vous constatez surement qu’au niveau de l’appel de ma fonction welcomeBuilder on envoie un tableau de String pour lequel on applique une mise en forme bien spécifique.

On écrit maintenant le code suivant :

@AttributedStringBuilder
func welcomeBuilder(name: String, titles: [String]) -> NSAttributedString {
    if !name.isEmpty && !titles.isEmpty {
        Text("Hello ")
        Text(name).color(.red)
        for title in titles {
            Text(title).font(.systemFont(ofSize: 20))
        }
    } else {
        Text("No Title")
    }
}

pareil à la structure conditionnelle, le ResultBuilder ne sait pas comment il va faire pour itérer sur une séquence de valeur genre un tableau de String. Alors on doit l’informer à travers une méthode static qui est le buildArray:

static func buildArray(_ components: [NSAttributedString]) -> NSAttributedString {
   let attributedString = NSMutableAttributedString()
   for component in components {
      attributedString.append(component)
   }
return attributedString
}

En effet, le résultat de l’itération sur le tableau titles est un NSAttributedString que l’on retourne ensuite à la méthode principal static buildBlock.

notre DSL commence à prendre forme sauf que le résultat final n’est pas assez lisible:

L’idée maintenant c’est d’ajouter des séparateurs mais bien sur en utilisant notre langage spécifique AttributedStringBuilder

if !name.isEmpty && !titles.isEmpty {
   Text("Hello ")
   SpecialCharacters.lineBreak
   Text(name).color(.red)
   for title in titles {
       SpecialCharacters.comma
       SpecialCharacters.lineBreak
       Text(title).font(.systemFont(ofSize: 20))
   }
} else {
   Text("No Title")
}

Pour ce faire, j’ai ajouté un nouveau type énumère appelé SpecialCharacters avec deux cases jusqu’à maintenant:

enum SpecialCharacters {
    case lineBreak
    case comma
}

Afin que mon nouveau type soit reconnu par mon DSL, je dois implémenter une nouvelle méthode qui est le buildExpression:

static func buildExpression(_ expression: SpecialCharacters) -> NSAttributedString {
  switch expression {
     case .lineBreak:
       return Text("\n")
     case .comma:
       return  Text(",")
     }
}

Vous remarquez que la méthode transforme une expression à base de SpecialCharacters vers son equivalent NSAttributedString ou en autre Text.

Mais on remarquera qu’on a des erreurs bizarres après l’ajout de la méthode buildExpression:

Le problème provient du fait que la méthode sera appelé systématiquement pour chaque component quel que soit Text ou SpecialCharacters alors pour résoudre ce problème on doit implémenter buidExpression pour gérer le cas spécifique de Text :

static func buildExpression(_ expression: NSAttributedString) -> NSAttributedString {
    return expression
}

La différence réside dans le paramètre d’entré qui sera de type NSAttributedString.

Conclusion

Nous constatons qu’un ResultBuilder constitue un outil assez poussé pour créer des DSL élégants. Le sujet peut être creusé avec des exemples différents notre AttributedStringBuilder. Vous pouvez retrouvez dans ce repo GitHub plusieurs idées de DSL à base de result builder.

Références:

https://www.hackingwithswift.com/swift/5.4/result-builders

https://www.swiftbysundell.com/articles/deep-dive-into-swift-function-builders/

https://theswiftdev.com/result-builders-in-swift/

https://www.raywenderlich.com/books/swift-apprentice/v7.0/chapters/20-result-builders

https://martinfowler.com/dsl.html

Gestion des accès concurrentiels en iOS avec les Actors

Depuis la version 5.5 de Swift, Apple a introduit un nouveau type de données dit Actor. Le nouveau type permet d’assurer que l’état d’un objet de type référence ne peut pas être modifié que par un seul thread. Cela permet d’assurer l’atomicité des opérations et d’éviter les problèmes dit « Data Races » ou accès concurrentiel à une ressource partagée. Cette solution ne constitue pas une nouveauté car elles existent d’autres bien anciennes et connues comme les mutex, les sémaphores, les moniteurs etc.. (toutes ses mécanismes sont inclus par les API Lock et GCD au niveau de swift). La nouveauté , que Apple a crée un nouveau type qui abstrait derrière toutes les solutions qu’on a évoqué et qui assure une serialisation des actions dans un environnement multi-threading. Cette article constitue un peu un « état de l’art » des articles et des vidéos que j’ai lu et que j’ai regardé sur ce sujet et ne constitue pas une nouveauté en termes des connaissances.

Je commencerai tout d’abord par présenter les catégories des types de données en iOS. Puis Je présenterai avec des exemples réels des situations d’exclusion mutuelles concrètes et comment on peut les détecter avec Xcode. Ensuite , comment l’utilisation des actors peut résoudre les problèmes d’exclusion.

Référence vs copie(valeur)/ Heap vs Stack

Dans la majorité des langages de programmation, les types de données qu’on utilise se distingue par leur manière de stockage au niveau de la mémoire centrale. Cela influe ensuite sur comment on peut lire et surtout modifier nos données.

Swift distingue deux catégories de types de données selon ce critère. Ceux gérés par référence et d’autres par copie.

  • Les structures, les enum (tout type scalaire) sont en copie.
  • Les classes, les fonctions (y compris les closures) et maintenant les Actors sont en référence.

Au niveau mémoire, les types dits par copie ou valeur sont stockés au niveau de la pile( stack) du programme. Par contre, ceux par référence existent dans la mémoire dynamique dite heap.

La mémoire heap permet une allocation dynamique des ressources au moment de l’exécution (Runtime). Par contre la pile est une mémoire statique (allocation se fait au moment de la compilation) limitée en terme de capacités mémoire par rapport à la heap. Cependant cette mémoire est thread safe, vu qu’on peut pas avoir deux threads qui accèdent à la même stack. En autre, au niveau de la pile, il n’y pas d’objet partagé. Cela, élimine la possibilité d’avoir le problème d’exclusion mutuelle (Data Races). Par contre, au niveau de la heap vu que la mémoire peut être partagée entre plusieurs threads, le risque d’avoir deux threads qui accèdent au même objet est fort probable. Cela se manifeste généralement au niveau des tests réels sur les appareils mais il y a des outils qui nous permettent de détecter à travers Xcode les data races que je vais montrer ensuite 🙂

le multithreading Heap vs Stack

Détection des Data races:

Nous avons ce code qui permet d’incrémenter un compteur puis de créer de créer un taskGroup pour 10000 appels de la méthode Increment.

class Counter {
    private var accounter = 0
    func increment() async {
        accounter += 1
        print("\(accounter) - \(Thread.current)")
    }
}

class AccounterViewModel {
    let counter = Counter()
    func printMessage() async {
        _ = await withTaskGroup(of: Void.self, body: { group -> Void in
            for _ in 1...10000 {
                group.addTask{ [weak self] in
                    await self?.counter.increment()
                }
            }
        })
    }
}

struct ContentView: View {
    var viewModel = AccounterViewModel()
    var body: some View {
        Text("Hello, world!")
            .padding()
            .task {
                await viewModel.printMessage()
            }
        
    }
}

Si vous exécutez ce code, vous allez voir que tout passe correctement à premier vu au niveau du terminal. Vous aurez un affichage normale de 1 à 10000. En effet, pour détecter qu’il y a un problème, il faut activer la détection des data races au niveau de Xcode de cette manière:

  • On sélectionner le schema de la target -> on clique sur éditer le schema -> on choisit l’action run -> puis l’option diagnostics -> on coche l’option thread Sanitizer
Activation du Thread Sanitizer

Maintenant, si vous exécutez une autre fois, vous aurez une alerte et un blocage d’exécution au niveau de deux thread (par exemple dans mon cas T1 et T6) qui veulent incrémenter en même temps la variable partagé accounter.

Détection de la data race

Le compilateur est généreux :), il nous indique clairement la ligne qui a déclenché le problème

indication du problème par Xcode

NB: L’activation de thread Sanitizer peut agir sur la performance de la machine. Il est recommandé de ne pas activer énormément et surtout pas sur la CI/CD. Pour plus d’informations sur ce sujet, vous pouvez consulter cet article.

Une solution avec GCD

Une première solution pour garantir l’exclusion mutuelle c’est d’utiliser le GCD. On peut utiliser un dispatch queue de type concurrent avec une barrier qui contrôle l’accès en écriture sur la variable accounter

 private var queue = DispatchQueue(label: "accounter", attributes: .concurrent)
    func increment() async {
        queue.sync(flags: .barrier) {
            accounter += 1
            print("\(accounter) - \(Thread.current)")
        }
    }

Utilisation des actors

Afin de résoudre ce problème, la solution est devenu très simple avec Apple, c’est juste de transformer notre class Counter vers un actor

actor Counter {
    private var accounter = 0
    func increment() {
        accounter += 1
        print("\(accounter) - \(Thread.current)")
    }
}

Un actor se comporte de la même manière qu’une classe (implémente un protocole, on peut l’étendre…) et il existe dans le heap vu qu’il est de type référence. Cependant on peut pas hériter d’un actor ce qui implique qu’on peut pas utiliser les annotations override, final, open et pas de convenience Initializer :).

RQ: La méthode increment ne contient pas async dans sa déclaration vu que toute fonction ou attribut existant dans un actor est par défaut async et isolé.

@MainActor

L’annotation @MainActor vient pour remplacer API GCD Dispatch.queue.async. Elle permet de s’assurer qu’un code qui agit sur la partie UI s’execute dans le main thread. J’ai modifié le code afin d’afficher les valeurs des compteurs de cette manière:

actor Counter {
    private var accounter = 0
    func increment() -> Int {
       accounter += 1
       return accounter
    }
}

class AccounterViewModel {
    let counter = Counter()
    func printMessage() async -> String {
        var finalString = ""
        _ = await withTaskGroup(of: Int.self, body: { group -> Void in
            for _ in 1...10000 {
                group.addTask{ [unowned self] in
                    let currentCounter = await self.counter.increment()
                    return currentCounter
                }
            }
            for await counterValue in group {
                finalString += "\(counterValue) \t"
            }
        })
        return finalString
    }
}

La méthode increment retourne un entier et chaque task crée va s’occuper de retourner un entier. Vous avez remarqué que le type de retour de chaque task du group est maintenant Int (withTaskGroup(of: Int.self). Ensuite, on parcourt notre groupe de valeur et on ajoute chaque valeur à la chaine finalString avec un petit espace :). Maintenant, on passera à la modification du contentView

struct ContentView: View {
    var viewModel = AccounterViewModel()
    @State var text: String?
    var body: some View {
        Text( text ?? "Hello, world!")
            .padding()
            .task {
                text = await viewModel.printMessage()
            }
    }
}

@State permet de modifier une variable de type valeur à l’intérieur d’une struct. Il ne faut pas oublier qu’au moment qu’on modifie un attribut dans une structure, on modifie toute la structure derrière (une nouvelle structure est crée derrière).

Vous remarquez aussi ce message de warning qui nous indique que le système craint que la modification de la valeur text peut ne pas se faire dans le main thread. Ce message ne peut être afficher qu’en activant un swift flags au niveau de build settings de cette manière:

  • -Xfrontend
  • -warn-concurrency
  • -Xfrontend
  • -enable-actor-data-race-checks

Activation Concurrency et data races warning

Une des solutions est soit c’est d’ajouter l’annotation @MainActor avant la structure ContentView

@MainActor
struct ContentView: View {.....}

ou de déclarer notre viewModel comme étant MainActor cela nous ramène à le rendre adapté au traitement asynchrone d’un actor. Une solution est de le mettre compatible avec le protocole ObservableObject et de l’annoter avec @StateObject lors de la déclaration.

Problème déclaration de viewModel comme @MainActor

@MainActor class AccounterViewModel: ObservableObject { ...}
struct ContentView: View {
@StateObject private var viewModel = AccounterViewModel()
....}

Références

https://www.hackingwithswift.com/books/ios-swiftui/why-state-only-works-with-structs

https://www.andyibanez.com/posts/structured-concurrency-with-group-tasks-in-swift/

https://www.swiftbysundell.com/articles/swift-actors/

https://www.guru99.com/stack-vs-heap.html

fr_FRFrench