0

Approfondir les collections avec les closures et les prédicats

Cet article n'a pas été mis à jour depuis plus d'un an, il est possible que certaines informations ne soient plus à jour. Si vous rencontrez des erreurs ou des différences en le suivant, n'hésitez pas à commenter pour me le signaler.

Bonjour / bonsoir à tous ! De retour en ligne et en force pour en apprendre toujours plus sur le Swift, aujourd’hui nous parlerons des tableaux et autres dictionnaires qui sont un outil indispensable à la création d’apps (dans tous les langages d’ailleurs) . Nul besoin de dépoussiérer votre vieux Larousse pour bien comprendre ce cours, je m’efforcerai que vous n’ayez pas besoin d’un dictionnaire-vous même. Alors allons-y pour ce cours holistique, quasi corcusant, de toute alacrité !

Moi en train de rédiger cette dernière phrase…

Trouver des éléments dans un tableau

Prenons une liste de villes françaises avec laquelle nous souhaiterions pouvoir vérifier si l’une ou l’autre est bien dans la liste. On aurait par exemple :

1
2
3
4
5
6
7
8
9
10
let villes = [
    "Nice",
    "Toulouse",
    "Starsbourg",
    "Nantes",
    "Brest",
    "Perpignan",
    "Lille",
    "Angers"
]

Comment vérifier qu’un nom de ville entré par l’utilisateur soit bien présent dans notre liste ? Tiens d’ailleurs faisons-en un simple exercice, le but est de créer une fonction qui à partir de cet Array et d’une String retourne un Bool étant vrai si la chaine de caractères se trouve dans la liste et faux si elle ne s’y trouve pas. En voici la déclaration pour simplifier :

func testEntreeListe(liste: [String], entree: String) -> Bool
Voir la correction
1
2
3
4
5
6
7
8
9
10
11
func testEntreeListe(liste: [String], entree: String) -> Bool {
    for element in liste {
        if element == entree {
            return true
        }
    }
    return false
}

testEntreeListe(liste: villes, entree: "Nice")      // true
testEntreeListe(liste: villes, entree: "Marseille") // false

 

Pour un code aussi simple et élémentaire, on pourrait s’attendre à ce que notre ami le Swift ait déjà fait le travail pour nous, et en effet c’est le cas ! En effet les Array contiennent bon nombre de méthodes bien utiles permettant de naviguer rapidement et simplement dans leur contenu. En l’occurence il nous suffit d’appeler la méthode .contains(_ element: Element) -> Bool pour obtenir immédiatement notre réponse :

1
2
villes.contains("Nice")         // true
villes.contains("Marseille")    // false

Mais c’est ici que les problèmes commencent, car en effet en tapant cela dans Xcode, on voit que l’autocomplétion de l’éditeur nous propose deux alternatives avec des signatures quelques peu différentes :

  • .contains(_ element: Element) -> Bool
    Returns a Boolean value indicating whether the sequence contains the given element.
  • .contains(where predicate: (Element) throws -> Bool) rethrows -> Bool
    Returns a Boolean value indicating whether the sequence contains an element that satisfies the given predicate.

Bon la première signature on la comprend, on vient de la voir, elle est facile. Mais la deuxième semble un peu plus barbare et possède pourtant une description quasi identique. On va donc voir un peu comment s’en servir.

Comment ça fonctionne ?

Cette signature étrange signifie en réalité qu’il nous est possible avec cette fonction de définir nous même les conditions avec lesquelles on considèrera qu’un élément du tableau correspond à notre recherche. La méthode basique (celle que nous venons de voir) ne va vérifier que pour une égalité parfaite entre les éléments du tableau et l’élément recherché, grâce à cette nouvelle méthode on peut tout simplement passer nos propres paramètres de recherche. Voyons sa syntaxe :

1
2
3
4
villes.contains(where: {
    (element: String) -> Bool in
    return element == "Nice"
})                              // true !!

Alors première chose que l’on remarque c’est que l’on doit désormais fournir un attribut where à la méthode afin de la distinguer de sa consoeur. En valeur elle prendra ce que l’on appelle une closure (désolé je n’ai pas trouvé de traduction suffisamment correcte ici), c’est à dire du code à exécuter qui retournera une valeur typée, comme une fonction dont le contenu serait directement passé en attribut d’une autre. On voit donc que la valeur de l’attribut where est en clair du code Swift écrit entre accolades dont l’exécution en l’occurence retournera un booléen. La première ligne de cette closure va servir de “déclaration de fonction” pour le code qui va s’exécuter, en clair c’est l’équivalent d’écrire une fonction classique, mais pas tout à fait avec la même syntaxe :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Closure :
{
    (element: String) -> Bool in
    // code à exécuter
    return true
}

// Est équivalent à

// Déclaration classique :
func (element: String) -> Bool {
    // code à exécuter
    return true
}

On voit bien la similitude des deux déclarations, les différences étant bien sûr que l’une est entièrement contenue entre accolades et pourra donc être passée en argument alors que l’autre est purement déclarative, et on remarque l’utilisation dans la closure du mot-clé in qui permet de passer le paramètre element au reste de la fonction. Ainsi, la méthode .contains(where:) va exécuter la closure qu’on lui passe pour chacun des éléments du tableau, et si cette closure retourne true au moins une fois, alors la méthode elle-même retournera true. C’est pour cela qu’on nomme ce type de closure un prédicat (dont la définition est particulièrement velue : c’est le syntagme verbal par rapport au syntagme nominal sujet) mais qui utilisé depuis l’anglais predicate signifie affirmer un attribut de quelque chose, en gros : filtrer !

Alors tout ça c’est bien joli mais on aurait bien voulu s’éviter cette cuisson cérébrale à feu doux si l’utilité est la même que ce que l’on a pu voir et comprendre en une ligne au début de l’article ! Et si vous pensez ça c’est qu’il vous manque encore un peu de temps de cuisson, car en effet désormais il nous est possible de filtrer bien plus qu’avec un simple égalité. Par exemple, si l’on souhaite pouvoir savoir si la ville de “tOuLoUSE” est contenue dans notre tableau, on ne pourrait pas avec une égalité parfaite à cause de la casse (majuscules / minuscules). Eh bien désormais cela devient carrément simple, par exemple en forçant les String en majuscules dans notre closure : .uppercased(), on peut les comparer sans prendre en compte la casse :

1
2
3
4
villes.contains(where: {
    (element: String) -> Bool in
    return element.uppercased() == "tOuLoUSE".uppercased()
})                              // true !!

Notons que ceci n’est pas la manière “propre” de comparer deux chaines de caractères sans prendre en compte la casse, il faut normalement utiliser l’égalité a.caseInsensitiveCompare(b) == .orderedSame mais celle-ci nécessite l’importation du framework Foundation au début de fichier swift avec import Foundation

Tiens d’ailleurs petite aparté sur la syntaxe des closures, ce que je vous ai présenté jusque là c’est une déclaration très verbeuse et redondante. En effet lorsqu’on travaille en Swift et que tout est obligatoirement typé, il n’est pas forcément nécessaire de toujours re-déclarer les types attendus sur chaque variable ou chaque retour même si cela permet une meilleure lisibilité du code dans le temps et est donc une bonne pratique. Parfois, il peut être avantageux d’en écrire moins pour gagner en lisibilité selon la complexité du reste du code. En l’occurence il est possible d’ignorer totalement les types dans la déclaration de la closure :

1
2
3
4
villes.contains(where: {
    element in
    return element.uppercased() == "tOuLoUSE".uppercased()
})

Vu que l’on sait déjà que notre tableau villes ne contient que des strings et que la closure renverra de toute façon un booléen pour être utilisable, alors on peut raccourcir de cette façon. Mais on peut aller encore un peu plus loin, car en effet puisque l’on sait que lorsqu’on exécute une closure sur un tableau il n’y aura systématiquement qu’un seul élément à passer en paramètre (même chose pour un dictionnaire où l’on sait que ce sera systématiquement deux éléments) alors il est possible de remplacer sa déclaration par une méta-variable : $0 :

1
2
3
villes.contains(where: {
    return $0.uppercased() == "tOuLoUSE".uppercased()
})

Mais on peut aller encore plus loin (pas beaucoup) ! Car en réalité le tout-puissant Swift nous permet pour gagner toujours plus en lisibilité d’écrire la closure à l’extérieur même de l’attribut, et de ce fait de le faire disparaitre. C’est pas clair à expliquer alors voilà comment faire :

1
2
3
villes.contains {
    $0.uppercased() == "tOuLoUSE".uppercased()
}

Là tout de suite on lit facilement que l’on vérifie si le tableau contient un élément qui valide ce code, c’est simple.

Comme je l’ai dit, tout ceci est strictement identique pour nos amis les dictionnaires, à la seule différence près que les closures accepterons deux paramètres au lieu d’un seul : la clé et la valeur. Allez encore un petit exercice, je vous donne un tableau avec en clé des noms de villes, en valeur leur populations respectives, et vous devez me bâtir une closure qui me dira par exemple si une des villes possède plus d’un million d’habitants, à vos méninges !

1
2
3
4
5
6
7
8
9
10
let dict: [String: Int] = [
    "Nice" : 341934,
    "Toulouse" : 509946,
    "Starsbourg" : 293914,
    "Nantes" : 331439,
    "Brest" : 140251,
    "Perpignan" : 118589,
    "Lille" : 234822,
    "Angers" : 158744,
]
Voir la correction
1
2
3
4
5
6
7
8
9
10
dict.contains(where: {
    (key:String, value:Int) -> Bool in
    return value > 1000000
})                          // false

// -------- OU --------

dict.contains {
    $1 > 1000000
}                           // false

 

Sur le même principe que .contains(), il existe d’autres méthodes de recherche dans les collections utilisant les prédicats, sans rentrer dans le détail pour chacune d’elle, en voici une liste :

  • .first(where:) : retourne le premier élément dans la collection dont le prédicat est validé
  • .last(where:) : retourne le dernier élément dans la collection dont le prédicat est validé
  • .firstIndex(where:) : retourne l’index du premier élément dans la collection dont le prédicat est validé
  • .lastIndex(where:) : retourne l’index du dernier élément dans la collection dont le prédicat est validé

Les transformations

Il existe d’autres méthodes que ces filtres de recherche pour agir sur les tableaux de manière rapide, simple et lisible, toujours en utilisant des closures. Cette fois-ci ce sera pour transformer lesdits tableaux en d’autres, sans avoir à les passer dans des boucles. Par exemple, disons que nous souhaitons depuis notre liste de villes, obtenir la même liste mais avec les noms de villes en lettres majuscules, eh bien il va nous suffire d’appeler la méthode .map() avec une closure qui au lieu de retourner un booléen cette fois-ci retourne l’objet transformé :

1
2
3
4
5
6
7
8
9
10
villes.map({
    (element: String) -> String in
    return element.uppercased()
})

// -------- OU --------

villes.map {
    $0.uppercased()
}

Notons ici qu’il est tout à fait possible de retourner un type totalement différent du type des éléments dans le tableau, comme par exemple un object d’une classe personnalisée. Notons également que le tableau original n’est pas modifié, c’est bien un nouveau tableau qui est retourné par la méthode .map(). Le tableau ainsi généré aura obligatoirement autant d’éléments que le tableau source, et ce même si notre prédicat retourne nil dans certains cas. Par exemple imaginons que l’on ne retourne l’élément que si le nom de la ville a plus de six lettres :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let villesMajuscules: [String?] = villes.map( {
    (element: String) -> String? in
    if element.count > 6 {
        return element.uppercased()
    }
    return nil
})

// Retournera :
// [
//     nil,
//     Optional("TOULOUSE"),
//     Optional("STRASBOURG"),
//     nil,
//     nil,
//     Optional("PERPIGNAN"),
//     nil,
//     nil
// ]

On se retrouve dans ce cas avec tout un tas de nil au milieu et par conséquent un nouveau tableau avec des éléments obligatoirement de types optionnels, ce qui n’est souvent pas idéal. Pour remédier à cela il existe un autre moyen d’effectuer un map, c’est d’effectuer un .compactMap(). Cette méthode agit exactement de la même façon que la précédente, avec la subtilité de nous débarrasser des nil encombrants et nous retourne un tableau tout propre sans type optionnel :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
villes.compactMap( {
    (element: String) -> String? in
    if element.count > 6 {
        return element.uppercased()
    }
    return nil
})

// Retournera :
// [
//     "TOULOUSE",
//     "STRASBOURG",
//     "PERPIGNAN"
// ]

Et en appliquant la compression de code que l’on aime tant en Swift, on voit tout de suite la puissance de l’outil :

1
2
3
villes.compactMap {
    $0.count > 6 ? $0.uppercased() : nil
}

Allez petit exercice (décidément vous êtes gâtés aujourd’hui !), vous allez à partir du dictionnaire des villes et de leur population me retourner un tableau des villes possédant plus de 300000 habitants 🙂

Voir la correction
1
2
3
4
5
6
7
8
9
10
11
12
13
dict.compactMap({
    (key: String, value: Int) -> String? in
    if value > 300000 {
        return key
    }
    return nil
})

// -------- OU --------

dict.compactMap {
    $1 > 300000 ? $0 : nil
}

Les tris

Allez c’est la dernière partie je vous promet, et pour cette dernière partie on va voir comment se servir de cette mécanique des closures pour nous aider à trier nos collections. Et cette fois-ci le nom de la méthode à utiliser est assez évident, il s’agit de .sort(). Vous commencez à connaitre le principe, on va lui passer une closure en attribut qui elle-même contiendra deux éléments. Alors oui, comme on va devoir comparer les éléments entre eux, il va bien nous en falloir deux. En retour on donnera toujours un booléen, qui indiquera si le premier élément devra se situer avant le deuxième (true) ou inversement (false). Voyons ça en code, on va trier les villes par la longueur de leur nom :

1
2
3
4
villes.sort(by: {
    (first: String, second: String) -> Bool in
    return first.count < second.count
})
Cannot use mutating member on immutable value: ‘villes’ is a ‘let’ constant

 

NOOOOOOON ! Tout ce temps sans une erreur et il suffit qu’on arrive à la fin pour se faire avoir ! Alors oui c’est parce qu’en effet cette méthode a un comportement un peu différent de ses consœurs car elle modifie directement le tableau source. Pas de valeur de retour, on modifie le tableau original, et en l’occurence j’avais déclaré une constante qui par définition ne peut pas être modifiée. Alors pour résoudre le problème on peut certes transformer cette dernière en variable (c’est d’ailleurs ce que nous propose de faire Xcode par défaut) mais en l’occurence je vais me servir de cette occasion pour vous présenter le pendant de cette méthode qui ne modifie pas le tableau original mais nous en retourne un nouveau, comme toutes les autres méthodes vues dans cet article : .sorted(). Alors non, la différence n’est pas vraiment énorme, mais il faut la connaître ! Alors corrigeons notre code et voyons ce qu’il se passe :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
let villesRangees = villes.sorted(by: {
    (first: String, second: String) -> Bool in
    return first.count < second.count
})

// Retournera :
// [
//     "Nice",
//     "Brest",
//     "Lille",
//     "Nantes",
//     "Angers",
//     "Toulouse",
//     "Perpignan",
//     "Starsbourg"
// ]

Pas mal non ? C’est français ! Bon il semblerai que l’on ait finit car désormais vous possédez la toute-pouissance des closures et des prédicats en Swift, mais une chose me chiffonne quand même avant de vous libérer. Voyez-vous Nantes et Angers possèdent toutes les deux 6 lettres alors j’ai le sentiment qu’elles devraient se trouver rangé dans le tableau par ordre alphabétique par conséquent… Vous pouvez faire ça pour moi ? Pour rappel il est possible de comparer l’ordre alphabétique des String avec les mêmes comparateurs que les nombres : <, >.

Voir la correction
1
2
3
4
5
let villesRangees = villes.sorted(by: {
    $0.count == $1.count ? $0 < $1 : $0.count < $1.count
})

// Pas de version verbeuse cette fois-ci, vous avez compris le fonctionnement !

 

On y est arrivé ! Je sais que c’est un peu long et probablement assez intense mais c’est bien là une étape absolument indispensable pour pouvoir travailler proprement en Swift sans avoir des boucles dans tous les sens. Un aspect que l’on a pas abordé est la performance, il faut savoir qu’utiliser ces méthodes toutes faites est bien souvent beaucoup plus efficace niveau RAM et CPU que d’implémenter des méthodes maison, d’où leur importance. Sur ce je vous dit à la prochaine à nouveau pour les collections car j’ai menti, on a pas finit… 😀

Suite du tutoriel Aller plus loin avec le Swift

En cours de rédaction...

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *