announcement

Actualités


Comprendre les différences d'approches en dévelopement logiciel


Des développeurs/ programmeurs de profils et de compétences différents ont des approches distinctes pour résoudre un même problème ou concevoir un logiciel. Cela conduit à produire des solutions très hétérogènes. Ces manières distinctes seront étudiées à la lumière d'un exemple et nous en verrons les avantages et inconvénients.

Différentes approches dans la conception logicielle

Développement low-code

Le low-code ou no-code est l'activité la plus éloignée de ce que l'on désigne habituellement par programmation, puisqu'il est question d'utiliser le moins de code possible. Vous l'avez certainement pratiqué si vous avez utilisé une application de tableur (l'outil de low-code le plus courant). Il y a aujourd'hui, un grand choix d'applications pour créer la votre via une interface graphique. Cela se fait généralement en utilisant des composants pré-construits, en remplissant des champs, en ordonnant ces différent éléments et en les connectant les uns aux autres jusqu'à obtenir le design d'application que vous souhaitez.

Le développement no-code permet des développements rapides lorsque les besoins ne nécessitent pas de personnalisation approfondie ou que le type d'application et ses prérequis sont connus et maîtrisés, tels que les développements de sites internet, de jeux vidéos, de workflows, d'ETL, ... De plus en plus de développeurs sont concernés par le low-code, notamment car les applications pour le pratiquer ont profitées de nombreuses améliorations ces dernières années, en particulier grâce à l'intelligence artificielle.

Cet article n'approfondira pas ce profil, ni cette activité, car notre fil rouge sera un code source à implémenter.

Développement logiciel

Le développement logiciel1 inclut diverses activités, toutefois, nous simplifirons sa définition comme l'activité de designer et produire un logiciel par l'utilisation d'algorithmes et processus connus. Ainsi, un développeur logiciel est un artisan du logiciel mêlant des pratiques empiriques avec sa propre expérience du métier. Les algorithmes développés sont constitués de structures simples de code telles que les conditions et les boucles.

Les programmeurs sont essentiellement des développeurs logiciel (quoique puisse mentionner le diplôme ou l'intitulé d'un poste).

Ingénierie logicielle

L'ingénierie2 est l'usage de principes et processus de nature scientifique pour le design et la construction d'un système. Ainsi, l'ingénirie logicielle3 est l'activité de designer et construire un logociel en se référant à ces principes et processus scientifiques. Un ingénieur logiciel peut donc être vu comme un scientifique mettant en œuvre des modèles et connaissances scientifiques pour élaborer un logiciel.

L'informatique est un champ appliqué des mathématiques, leur emploi favorise donc la résolution de problèmes algorithmiques tout en atteignant un but d'abstraction.

L'ingénirie logicielle est moins commune car elle requiert des champs de connaissances et d'expériences à la fois plus larges et plus approfondis.

Un exemple pour mettre en avant des différences d'approche

Pour illustrer qu'un même problème peut être solutionné en prenant des voies différentes, nous allons prendre un exemple trivial que vous avez peut-être déjà rencontré.

Le problème que nous allons essayer de résoudre est de compter la somme, par enfant, de leur argent de poche, reçu plusieurs mois durant.

  • chaque mois, un enfant reçoit ou non de l'argent de poche
  • un mois est représenté par une map associant à chaque nom d'enfant (clé) une somme positive en euro (value)
  • nous considérons que les noms sont uniques, ainsi, les occurrences d'un même nom entre des mois différents référencent le même enfant
  • le résultat escompté est une nouvelle map associant à chaque enfant la somme d'argent de poche reçu durant ces différents mois

Par exemple, nous pouvons commencer en prenant les 2 mois (janvier et février ; mars viendra plus tard) avec les données suivantes:

Argent de poche pour le mois de janvier:

val jan = Map(
    "Nolan" -> 10,
    "Wendy" -> 15,
    "Tuck"  -> 10,
    "Meddi" -> 10
)

Argent de poche pour le mois de février:

val feb = Map(
    "Mandy"   -> 12,
    "Quentin" -> 15,
    "Nolan"   -> 10,
    "Wendy"   -> 15
)

Argent de poche pour le mois de mars:

val mar = Map(
    "Nolan"   -> 10,
    "Wendy"   -> 15,
    "Tuck"    -> 10,
    "Meddi"   -> 10,
    "Mandy"   -> 12,
    "Quentin" -> 15
)

Le résultat attendu pour la somme entre les mois de janvier et février, et donc des maps jan et feb est:

Map(
    "Nolan"   -> 20,
    "Wendy"   -> 30,
    "Tuck"    -> 10,
    "Meddi"   -> 10,
    "Mandy"   -> 12,
    "Quentin" -> 15
)

Si vous êtes programmeur, n'hésitez-pas à accepter le challenge et prenez quelques minutes pour réfléchir, voir prenez le temps de coder une solution. Dans ce second cas, mesurez le temps requis, cela vous sera utile pour la suite.

Approche développement logiciel

L'approche la plus courante est de diviser un problème en plusieurs problèmes de taille plus réduite et ce jusqu'à obtenir un sous problème d'une taille suffisament réduite pour être conceptualisé et mis sous forme de code. Nous allons donc porter notre attention à la fusion de 2 maps (janvier et février) puis généraliser le comportement (en effet, on pourrait sintéresser à la somme d'argent de poche sur une année complète, par exemple).

Version impérative

Pour fusionner nos 2 maps nous allons utiliser une troisième map dans laquelle nous accumulerons les paires clé/ valeur. Tout d'abord, nous itérerons sur la première maps, puis pour chaque clé nous sommerons les valeurs des 2 maps. Enfin, nous itérerons sur la seconde map et pour chaque clé qui n'était pas dans la première map, nous l'ajouterons, avec sa valeur, dans la troisième map.

Cet algorithme est assez simple ; le seul risque vraisemblable est d'oublier la seconde itération.

Algorithme pour 2 maps
// we create a map where the result will be stored
val result = mutable.Map.empty[String, Int]

// we add all children from the january map
// and for all of them we sum the received pocket money during january and february
for ((name, amount) <- jan) {
    result += name -> (feb.getOrElse(name, 0) + amount)
}

// we also have to add the children that are in the
// february map but not in the january one
for ((name, amount) <- feb) {
    if (!(jan contains name)) {
      result += name -> amount
    }
}
Généralisation de l'algorithme

Nous disposons d'une solution pour 2 mois. À présent nous la généralisons pour une liste de mois.

Une fonction mergeAll va prendre en paramètre une liste de maps et utiliser la fonction merge correspondant au comportement implémenté ci-dessus.

// we wrap the previous solution in a function
// and rename jan/ feb to m1/ m2
def merge(m1: Map[String, Int], m2: Map[String, Int]): Map[String, Int] = {
  val result = mutable.Map.empty[String, Int]

  for ((name, amount) <- m1) {
    result += name -> (m2.getOrElse(name, 0) + amount)
  }

  for ((name, amount) <- m2) {
    if (!(m1 contains name)) {
      result += name -> amount
    }
  }

  result
}

// we create another function that will take a list of months in input
def mergeAll(months: List[Map[String, Int]]): Map[String, Int] = {

  if (months.isEmpty) { // the list may be empty
    Map.empty[String, Int]
  } else if (months.length == 1) { // or the list may contain only one month
    months.head // take the first element
  } else { // otherwise there are 2 or more maps in the list
    var result = Map.empty[String, Int]
    for (month <- months) {
      result = merge(result, month) // merge the current list with the accumulated result and overwrite previous result
    }
    result
  }
}

Avez-vous pensé aux cas aux limites ? Notamment au calcul de la somme de l'argent de poche pour 0 ou 1 mois seulement ?

Test

Tester du code organisé de manière impérative et contenant des boucles et conditions de branchements est souvent complexe, long et fastidieux, notamment dans la création de jeux de données.

À l'instar du test de la fonction merge que l'on sera amené a tester en utilisant un exemple de jeu de données entrée/ sortie. Un tel jeu de données est généralement calculé manuellement ou pire, produit par la sortie de la fonction que l'on cherche à tester ! Le test ainsi rédigé assure que le fonctionnement de notre algorithme est pour le jeu de données sélectionné et uniquement celui-ci.

Afin de conserver nos tests concis, nous référencerons nos précédentes données.

assert(merge(Map(), Map()) == Map())
assert(merge(jan, Map()) == jan)
assert(merge(Map(), jan) == jan)

val janAndFeb = Map(
    "Nolan"   -> 20,
    "Wendy"   -> 30,
    "Tuck"    -> 10,
    "Meddi"   -> 10,
    "Mandy"   -> 12,
    "Quentin" -> 15
)
assert(merge(jan, feb) == janAndFeb)

Tester la fonction mergeAll est bien plus compliqué car les entrées de la fonction sont elles-mêmes plus complexes.

val janAndFebAndMar = Map(
    "Nolan"   -> 30,
    "Wendy"   -> 45,
    "Tuck"    -> 20,
    "Meddi"   -> 20,
    "Mandy"   -> 24,
    "Quentin" -> 15
)

assert(mergeAll(List()) == Map())
assert(mergeAll(List(Map())) == Map())
assert(mergeAll(List(Map(), Map())) == Map())
assert(mergeAll(List(jan)) == jan)
assert(mergeAll(List(jan, feb)) == janAndFeb)
assert(mergeAll(List(feb, jan)) == janAndFeb)
assert(mergeAll(List(jan, feb, mar)) == janAndFebAndMar)

Version fonctionnelle

Aux fins de présenter une méthode alternative, le code qui suit présente une implémentation basée sur le paradigme fonctionnel. Le but est de garder cet exemple simple. À ce titre des principes un peu plus avancés tels que le currying, ou de la mutabilité locale pour améliorer la performance ne seront pas utilisés (il ne sont pas à l'ordre du jour de cet article). Cependant, pour être honnête, l'implémentation qui suit est d'un niveau au dessus de la précédente et implique quelques connaissances supplémentaires. Elle peut être vue comme une solution intermédiaire entre l'approche de développement logiciel et celle d'ingénierie logicielle. En effet, les principes de la programmation fonctionnelle trouvent leurs racines dans les mathématiques.

Algorithme pour 2 maps

Ici, la fusion des 2 maps a été scindée en 2 fonctions. La première calcule la somme des valeurs des deux maps pour une clé donnée et retourne un tuple clé/ valeur, soit le nom de l'enfant et la somme de son argent de poche sur les deux mois. Cette fonction a été extraite pour améliorer la lisibilité de l'exemple (en pratique, elle est pure, donc référentiellement transparente ; l'appel de fonction pourrait donc être remplacé par sa définition).

La seconde fonction, quant à elle, itère sur les clés et accumule les paires générées (par la première fonction) dans une map immutable.

Ces fonctions ont été dissociées car elles représentent, déjà, une généralisation. En effet, une map peut être vue comme un iterable de tuples de 2 éléments, où le premier est une clé unique, tandis que le second est la valeur associée à cette clé.

// build a key/ value pair and sum the values of two maps on the given key
def pairSum(key: String, m1: Map[String, Int], m2: Map[String, Int]) = key -> (m1.getOrElse(key, 0) + m2.getOrElse(key, 0))

// merge 2 maps using an iteration on keys and an accumulative map
def merge(m1: Map[String, Int], m2: Map[String, Int]): Map[String, Int] = 
  (m1.keySet ++ m2.keySet).foldLeft(Map.empty[String, Int])((acc, key) => acc + pairSum(key, m1, m2))
Généralisation de l'algorithme

La fonction mergeAll a été conservée pour garder un découpage semblable à la précédente version. Néanmoins, le corps de cette fonction est suffisamment concis pour être utilisé directement, si on le désire.

def mergeAll(months: List[Map[String, Int]]): Map[String, Int] = 
  months.foldLeft(Map.empty[String, Int])(merge)

Nous avons écrit 3 fonctions pairSum, merge and mergeAll. Le corps de fonction de pairSum pourrait être inclus dans la fonction merge tandis que la fonction mergeAll s'avère de peu d'utilité.

def merge(m1: Map[String, Int], m2: Map[String, Int]): Map[String, Int] = 
  (m1.keySet ++ m2.keySet).foldLeft(Map.empty[String, Int])((acc, key) => acc + (key -> (m1.getOrElse(key, 0) + m2.getOrElse(key, 0))))

List(jan, feb, mar).foldLeft(Map.empty[String, Int])(merge)

La solution a notre problème précédent peut donc finalement tenir en 3 lignes de code.

Test

La testabilité de ces fonctions est nettement meilleure que celle de la version impérative de l'algorithme. Il est possible de rédiger des tests, sans s'imposer de restrictions inutiles, très génériques et qui gèrent sans les expliciter les cas aux limites (par exemple les listes ayant moins de 2 éléments).

Pour se faire, nous allons utiliser une méthode de test appelée property based testing qui consiste en la génération de données aléatoires satisfaisant les propriétés que l'on définit (ici, nous nous focaliseront uniquement sur le fait de satisfaire un type donné Map[String, Int], toutefois il est possible d'imposer aux entiers d'être positifs ou divisibles par 2, etc). Le framework va ainsi générer des dizaines, centaines voir davantage de données (selon configuration) et s'assurer qu'elles vérifient toutes, sans exception, le test.

Pour tester la fonction pairSum nous nous assurons qu'il est possible de calculer la somme des valeurs de 2 maps pour une clé donnée, et cela pour des maps qui contiennent ou non la clé mentionnée. On vérifié par ailleur la capacité de cette fonction à retourner un tuple associant cette même clé à cette somme.

Comme il vous est possible de le constater, aucune supposition n'est faite sur le jeu de données lui-même. Nous espérons seulement disposer de 2 maps aléatoires avec des clés au format String et aux valeurs au format Int. Toujours est-il que si ces données sont valides en tant qu'entrées de notre fonction, pour s'assurer que la fonction testée réalise bien le travail attendu, il nous est nécessaire de nous assurer de la présence (en ajoutant et éventuellement écrasant) ou non (en retirant) de la clé sur laquelle va porter notre test.

"pairSum" should "associate a key to the sum of its values from 2 given maps" in {
    forAll { (m1: Map[String, Int], m2: Map[String, Int], k: String, n1: Int, n2: Int) =>
        pairSum(k, m1 - k, m2 - k) shouldBe (k -> 0) // m1 and m2 do not contain the key
        pairSum(k, m1 - k, m2 + (k -> n2)) shouldBe (k -> n2) // m1 does not contain the key
        pairSum(k, m1 + (k -> n1), m2 - k) shouldBe (k -> n1) // m2 does not contain the key
        pairSum(k, m1 + (k -> n1), m2 + (k -> n2)) shouldBe (k -> (n1 + n2)) // m1 and m2 contain the key
    }
}

Pour tester la fonction merge nous allons vérifier que pour chaque paire de clé/ valeur en sortie, la valeur de la somme correspond effectivement à celle que l'on peut caculer en sommant toutes les valeurs associées à cette clé dans la liste de maps. Il est primordial de s'assurer que le set de clés en entrée et en sortie est le même (et que les clés ne sont pas perdues) car l'autre test serait alors toujours valide.

"merge" should "sum the values by key for 2 maps" in {
    forAll { (m1: Map[String, Int], m2: Map[String, Int]) =>
        merge(m1, m2).keySet == List(m1, m2).flatMap(_.keySet).toSet // ensure the keys are preserved
        merge(m1, m2).forall { case (key, sum) =>
            sum == List(m1, m2).flatMap(aMap => aMap.collectFirst { case (k, v) if k == key => v }).sum
        }
    }
}

Pour tester la fonction mergeAll il suffit de faire quelques modifications mineures à notre test de la fonction merge. Ce test rend évident le fait que merge et mergeAll sont très liées et que la seconde fonction est une généralisation de la première.

"mergeAll" should "sum the values having the same key from a list of maps" in {
    forAll { list: List[Map[String, Int]] =>
        mergeAll(list).keySet == list.flatMap(_.keySet).toSet // ensure the keys are preserved
        mergeAll(list).forall { case (key, sum) => // ensure that each the sum associated to a key is valid
            sum == list.flatMap(aMap => aMap.collectFirst { case (k, v) if k == key => v }).sum 
        }
    }
}

Approche ingénierie logicielle

Si vous maîtrisez la théorie des catégories4 vous avez, sans nul doute, reconnu que notre problème s'appuie sur un monoïde5 (commutatif) c'est-à-dire une structure algébrique munie d'une opération binaire, associative et d'un élément neutre.

Si en revanche cette notion ne vous est pas familière, voici quelques explications. Nous considérons que notre opération est applelée x, alors :

  • il existe un élément identité (élément neutre) Id tel que a x Id = Id x a = a
  • cette opération est associative, ainsi : a x (b x c) = (a x b) x c

Dans notre cas, l'élément identité est la map vide et l'opération binaire n'est autre que l'opération de fusion entre 2 map, qui somme les valeurs par clé pour obtenir une nouvelle strucutre de même forme (endomorphisme).

Si vous utilisez un monoïde commun (alors disponible implicitement par votre bibliothèque logicielle) la fusion de ces 2 maps peut être calculée aussi simplement que l'implémentation ci-dessous.

jan |+| feb // if you like symbols
jan combine feb // if you prefer the ascii alias

De surcroît, il est possible de généraliser, ce comportement, à une liste de maps, sans effort.

List(jan, feb, mar).combineAll

Observez comme la solution est abstraite et ne repose que sur des propriétés mathématiques.

Test

Dans la mesure où l'instance de monoïde est fournie par une bibliothèque, écrire un test qui existe déjà est redondant. Toujours est-il que pour le bénéfice du lecteur nous allons procéder au test de ce monoïde. C'est-à-dire que nous allons vérifier le respect des propriétés mathématiques d'un monoïde pour notre type de données.

Similairement au test précédent, nous allons utiliser le property based testing afin de vérifier que les propriétés mathématiques se vérifie pour un grand nombre de données. Le nom de notre test est Map[String, Int] et les lois associées à un monoïde commutatif vont être mise à l'épreuve sur des données aléatoires de type Map[String, Int].

checkAll("Map[String, Int]", CommutativeMonoidTests[Map[String, Int]].commutativeMonoid)

L'exécution de ce test produit une sortie similaire à:

[info] - Map[String, Int].commutativeMonoid.associative
[info] - Map[String, Int].commutativeMonoid.collect0
[info] - Map[String, Int].commutativeMonoid.combine all
[info] - Map[String, Int].commutativeMonoid.combineAllOption
[info] - Map[String, Int].commutativeMonoid.commutative
[info] - Map[String, Int].commutativeMonoid.is id
[info] - Map[String, Int].commutativeMonoid.left identity
[info] - Map[String, Int].commutativeMonoid.repeat0
[info] - Map[String, Int].commutativeMonoid.repeat1
[info] - Map[String, Int].commutativeMonoid.repeat2
[info] - Map[String, Int].commutativeMonoid.right identity

On voit que différentes propriétés ont été testées, pamis lesquelles celles d'identité et d'associativité mentionnées plus haut.

Il peut être déroutant de prendre conscience que nous n'avons pas testé le résultat que nous souhaitons obtenir mais que nous avons vérifié que le type de données utilisé répond à un ensemble de lois mathématiques.

Pour aller plus loin

Nous avons répondu à notre problématique initiale via 3 algorithmes différents. Toutefois, un projet vivant est amené à être mis à jour tôt ou tard.

Que se passe-t-il si l'on décide de changer le type représentant l'argent de poche de Int vers Double, BigDecimal ou de remplacer le nom de l'enfant par un identifiant de type Long ?

Les deux premiers arlgorithmes vont nécessiter une mise à jour pour refléter les changements de type de données.

En revanche, notez comme l'abstraction réalisée par l'utilisation des propriétés du monoïde ne trahit pas le type de données sous-jacent. Tant que le nouveau type de données satisfait les mêmes lois, alors, aucune mise à jour du code ne sera nécessaire. Vous l'aurez deviné Map[String, Double], Map[String, BigDecimal] ou Map[Long, Double] sont aussi des monoïdes communs !

Monoïde ou ne pas monoïde, telle est la question?

La disponibilité d'une instance de monoïde est vérifiée à la compilation. Si le code ne compile plus après une modification du type de données il suffit de répondre à une simple question.

Est-ce normal qu'aucune instance de monoïde ne soit trouvé pour mon type de données ?
Oui, mon type de données n'est pas un monoïde !
  • Parfait, il s'agit d'un bug trouvé dès la compilation car il est peu probable que les lois utilisées jusqu'alors n'aient plus leur raison d'être
  • Intéressant, peut-être que certaines propriétés ont été perdues (par exemple, la structure répond aux lois d'un semigroup, mais plus à celle d'un monoïde)
  • Dommage, les prérequis on tellement changés qu'il n'est plus possible de conserver ce niveau d'abstraction et qu'il est nécessaire d'écrire un algroithme plus proche de l'environnement métier
Non, mon type de données est un monoïde !
  • Il s'agit d'un type de données personnalisé (et donc qui n'est pas fourni par votre bibliothèque) il faudra donc écrire l'instance de monoïde associée

Avantages et inconvénients

Il est temps de comparer les différentes implémentations que nous avons vues.

Durée d'implémentation

Si vous avez tenté le challenge qui vous était proposé, vous avez à présent une bonne idée du temps nécessaire à l'écriture d'au moins l'une des solutions précédentes. Le temps peut varier légèrement, mais un temps de référence peut être de cet ordre:

  • 30 minutes ou plus pour l'implémentation impérative
  • 10 à 15 minutes pour la version fonctionnelle
  • 30 secondes ou moins pour la version abstraite

En terme de temps d'implémentation, les différences entre ces 3 versions sont très conséquentes. L'approche fonctionnelle est au moins 2 fois plus rapide à écrire que la version impérative, tandis que l'algorithme reposant sur l'utilisation d'une instance de monoïde l'est d'un facteur 60.

Perspectives de mises à jour du type de données

Cet aspect a été abordé un peu plus haut, nous nous intéressons, ici, uniquement à le quantifier en prenant un cas légitime tel qu'un remplacment de Int vers Double.

  • le premier algorithme requiert 8 mises à jour, soit au niveau des signatures de fonctions, soit dans les structures conditionnelles
  • le second algorithme nécessite près de 2 fois moins de mise à jour avec 5 occurrences
  • le dernier algorithme ne nécéssite aucune mise à jour

En ce qui concerne la maintenabilité associée à l'évolution du type de données l'abstraction conduit à réaliser des implémentations plus flexibles et en conséquence à réduire les besoins de mises à jour.

Perspectives de maintenabilité des tests

Tester un logiciel est une part importante du travail d'un programmeur. Le but du test n'est pas seulement de s'assurer du fonctionnement d'une fonctionnalité lors de son développement, mais surtout de pouvoir s'assurer que les modifications ultérieures du logiciel ne créent pas de régressions.

  • la capacité à tester le premier algorithme est faible puisqu'elle repose sur un seul exemple, arbitraire, sélectionné (certainement pour sa simplicité) par le développeur ; de plus le couplage entre le code et le test est si fort que le test nécessitera certainement d'être modifié à chaque mise à jour du code principal
  • le second algorithme acquiert un peu d'abstraction, il en résulte que le test aussi gagne en abstraction notamment sur les données utilisées qui, auto-générées ne nécessiteront pas ou peu de maintenance. Le test peut alors se préoccuper de vérifier des pré-conditions/ post-conditions qui elles n'ont que très peu de chances de changer
  • le dernier algorithme, ici, ne nécessite pas de test particulier (redondant puisque déjà testé dans la bibliothèque), toutefois l'écrire ne représente qu'une seule ligne

Les plaintes des programmeurs concernant l'impact du temps de maintenance des tests automatisés sont récurrentes. Le principal argument est que le ratio du temps de développment de nouvelles fonctionnalités divisé par le temps de mise à jour des tests impactés décroît à mesure que le projet grandit. Il y a une part de vérité dans cette assertion, mais, comme nous venons de le voir cela résulte essentiellement de choix de designs et d'implémentations. Plus l'abstraction du code est importante, moins il y a de tests à mettre à jour.

Perspective globale de maintenance

La maintenabilité générale d'un projet dépend de nombreux facteurs ; bien plus que ceux que nous avons mis en lumière. Nous supposerons donc que le projet à été l'objet de bonnes pratiques (documentation, commentaires, modularisation, ...). En conséquence de quoi nous focaliserons notre attention sur une métrique sous-estimée : le nombre de lignes de code.

Quand un nouveau membre joint une équipe projet, la taille générale d'un projet a de l'importance. Quand une mise à jour ou un fix est requis et que l'on cherche la position exacte où placer un patch, la taille de la fonctionnalité à lire, comprendre et s'approprier, a son importance. En outre, la productivité sur un projet décroît à mesure que sa taille s'accroît.6 Avec le recul de dizaines d'années de données et études on sait aussi que le nombre de bugs progresse avec le nombre de lignes de code7. Plus intéressant encore, le nombre de lignes de code, par jour, par programmeur, en moyenne, est très bas (10 à 50 lignes par jour) et n'est pas influencé par le langage.8

Revenons-en à l'objet initial.

  • l'algorithme impératif représente à lui seul 36 lignes de code et autant pour les données de test, soit 72 lignes au total pour cette implémentation.
  • l'approche fonctionnelle utilise quant à elle 29 lignes de code
  • la dernière implémentation ne compte qu'une seule ligne de code (une de plus si l'on ajoute un test redondant)

Ce constat est significatif, car non seulement, l'implémentation la plus courte est la plus rapide à écrire, mais qui plus est son poids ne vient pas s'ajouter comme une dette en terme de nombre de lignes de code.

Comprendre la répartition des programmeurs

Pour Alan Turing, le future de l'informatique avait une voie tracée par les mathématiques.9 Alors, pourquoi l'immense majorité des programmeurs pratiquent-ils le développement logiciel plutôt que l'ingénierie logicielle ?

La raison est la complexité réponse au besoin du marché.

Le nombre de développeurs double tous les 5 ans.10 Que signifie cette croissance exponentielle (aussi mauvais l'homme est-il dans la compréhension de la fonction exponentielle11) ?

  • le nombre de développeurs est plus grand que celui des enseignants requis pour assurer correctement leur formation
  • le niveau exigé pour l'obtention de diplômes doit être accessible à suffisamment d'individus pour satisfaire les besoins industriels
  • l'expérience moyenne est basse

La première assertion implique que la qualité de l'enseignement varie grandement entre différents développeurs par manque d'encadrement pédagogique.

Le second argument, concerne le fait que lorsqu'une sélection est établie pour extraire les meilleurs éléments d'un set fini (ici, des étudiants à diplômer ou embaucher ; mais il pourraît être question d'une taille minimale de capture pour des poissons, ...) et que le besoin requiert d'élargir cette sélection, cela ne peut être fait qu'en diminuant les exigences générales. Cela va à l'encontre même de l'amélioration du seuil de connaissances.

Le dernier point est à propos du manque de mentoring. Une telle croissance en nombre de programmeur a pour conséquence que les emplois sont dominés par des individus relativement jeunes. 1 programmeur sur 2 a moins de 5 ans d'expérience, 75% ont moins de 10 ans d'expérience, ... Cela signifie, aussi, que si vous êtes un programmeur qui lit cet article il n'y a que 25% de chance que vous ayez 33 ans ou plus, 12.5% que vous ayez 38 ans ou plus, ...

Avec ces raisons à l'esprit on réalise pourquoi le développement logiciel prévaut plutôt que l'ingénierie logicielle qui reste minoritaire dans l'industrie.

Par ailleurs, faire le choix de l'ingénierie logicielle, pour une entreprise, n'est pas un chemin sans embuche car les candidats qualifiés sont peu nombreux et suscitent l'intérêt de bien d'autres entreprises. Ils tendent donc à avoir des attentes plus hautes, quelles soient salariales, d'avantages en terme de qualité ou de contexte de travail, ... Il est aussi très compliqué de revenir en arrière car un membre insuffisamment qualifié ne peut être ajouté en renfort à une équipe car il ne sera pas en capacité de comprendre et prendre part à un projet en cours.

Heureusement, le jeu en vaut la chandelle. Nous avons suivi un seul et simple exemple mais les mathématiques on bien d'autres propriétés utiles dans l'élaboration d'un programme.

L'utilisation de ces propriétés peut améliorer de manière substantielle la qualité de vos logiciels en prevenant des erreurs, par design, ce qui est une préoccupation majeure lorsque plus de 75% du coût d'un logiciel ordinaire est attribué à sa maintenance.12 Ainsi, s'engager dans une voie qualitative dès la phase initiale d'un projet peut en réduire son coût total 13 en plus d'améliorer la satisfaction des clients et utilisateurs.

Faire usage d'abstraction améliore la productivité générale et limite les refactoring coûteux. De plus, de nombreuses études soulignent depuis plus de 50 ans et en dépit du caractère contre-intuitif, qu'il n'y a pas de relation entre la performance d'un développeur et son expérience, pire, la différence entre un développeur performant et un non performant est d'au moins un ordre de magnitude.14 À ce titre, investir dans des profils performants avec des compétences plus amples est une stratégie digne d'intérêt.

Pour conclure, si vous êtes un programmeur qui souhaite améliorer ses compétences mais que ce que vous venez de lire vous semble trop compliqué, souvenez vous que cela semble toujours impossible, jusqu'à ce que ce qu'on le fasse.15


Notes et références

Mathieu Prevel

Président de Dedipresta

format_quoteEnthousiaste de l'ingénierie logicielle, convaincu par la programmation fonctionnelle.format_quote

publié le 23 mars 2020

history

Archives