Edit comme j'avais des doutes sur le benchmark lancé depuis Intellij, j'ai recodé un comparatif complet qui aura tourné 4 heures sur ma machine
Avec Kotlin, sous Java 21, en utilisant JMH, j'obtiens des résultats en total contradiction avec ceux obtenus via IntelliJ. L'écart passe de 12% à 2,2% dans le pire des cas.
Pour mon benchmark, j'ai calculé la longueur d'une String
avec la fonction suivante : 2 * str.length + 1
. Le but étant d'illustrer quel type d'appel est le plus rapide pour effectuer ce calcul.
Voici mes tableaux de comparatifs :
Benchmark (Sorted by Worst) | Score | Error | Worst | Best | Units | Note Relative | Gain |
---|---|---|---|---|---|---|---|
ValueClass(str).size() | 2 252 | 34 | 2 218 | 2 287 | ops/us | 1,000 | 0,0 % |
build_lambda(str)() | 2 269 | 25 | 2 244 | 2 294 | ops/us | 1,012 | 1,2 % |
Class::size(str) | 2 282 | 37 | 2 245 | 2 320 | ops/us | 1,012 | 1,2 % |
OpenIntance(str).size() | 2 275 | 27 | 2 249 | 2 302 | ops/us | 1,014 | 1,4 % |
Instance(str).size() | 2 285 | 37 | 2 249 | 2 322 | ops/us | 1,014 | 1,4 % |
DataClass(str).size() | 2 273 | 22 | 2 251 | 2 295 | ops/us | 1,015 | 1,5 % |
object.method(str) | 2 272 | 19 | 2 253 | 2 291 | ops/us | 1,016 | 1,6 % |
this.size(str) | 2 282 | 28 | 2 254 | 2 310 | ops/us | 1,016 | 1,6 % |
build_anonymous_function(str)() | 2 266 | 5 | 2 261 | 2 271 | ops/us | 1,019 | 1,9 % |
Benchmark (Sorted by Best) | Score | Error | Worst | Best | Units | Note Relative | Gain |
---|---|---|---|---|---|---|---|
build_anonymous_function(str)() | 2 266 | 5 | 2 261 | 2 271 | ops/us | 1,000 | 0,0 % |
ValueClass(str).size() | 2 252 | 34 | 2 218 | 2 287 | ops/us | 1,007 | 0,7 % |
object.method(str) | 2 272 | 19 | 2 253 | 2 291 | ops/us | 1,009 | 0,9 % |
build_lambda(str)() | 2 269 | 25 | 2 244 | 2 294 | ops/us | 1,010 | 1,0 % |
DataClass(str).size() | 2 273 | 22 | 2 251 | 2 295 | ops/us | 1,011 | 1,1 % |
OpenIntance(str).size() | 2 275 | 27 | 2 249 | 2 302 | ops/us | 1,014 | 1,4 % |
this.size(str) | 2 282 | 28 | 2 254 | 2 310 | ops/us | 1,017 | 1,7 % |
Class::size(str) | 2 282 | 37 | 2 245 | 2 320 | ops/us | 1,021 | 2,1 % |
Instance(str).size() | 2 285 | 37 | 2 249 | 2 322 | ops/us | 1,022 | 2,2 % |
Benchmark (Sorted by Score) | Score | Error | Worst | Best | Units | Note Relative | Gain |
---|---|---|---|---|---|---|---|
ValueClass(str).size() | 2 252 | 34 | 2 218 | 2 287 | ops/us | 1,000 | 0,0 % |
build_anonymous_function(str)() | 2 266 | 5 | 2 261 | 2 271 | ops/us | 1,006 | 0,6 % |
build_lambda(str)() | 2 269 | 25 | 2 244 | 2 294 | ops/us | 1,007 | 0,7 % |
object.method(str) | 2 272 | 19 | 2 253 | 2 291 | ops/us | 1,009 | 0,9 % |
DataClass(str).size() | 2 273 | 22 | 2 251 | 2 295 | ops/us | 1,009 | 0,9 % |
OpenIntance(str).size() | 2 275 | 27 | 2 249 | 2 302 | ops/us | 1,010 | 1,0 % |
this.size(str) | 2 282 | 28 | 2 254 | 2 310 | ops/us | 1,013 | 1,3 % |
Class::size(str) | 2 282 | 37 | 2 245 | 2 320 | ops/us | 1,013 | 1,3 % |
Instance(str).size() | 2 285 | 37 | 2 249 | 2 322 | ops/us | 1,015 | 1,5 % |
Conclusion après benchmark
Créer des instances à la Yegor Bugayenko semble être globalemment l'une si ce n'est la meilleure des solutions lorsque l'on observe son positionnement qui est au pire moyen sinon deux fois premier.
J'ai passé le nouvel an avec @Kysofer & @Chlouloutte. Quand tous les enfants ont le même âge c'est vraiment sympa ; surtout quand ils se couchent tous en même temps :P
Mais passons, ce matin après ce qui sera déjà l'une des plus courtes nuit de cette nouvelle année, je discute avec @Kysofer de ces projets pour 2024, il me sort un truc improbable :
Je pense que je vais abandonner la programmation orientée objet pour la programmation fonctionnelle...
Là, je le regarde limite choquée... Il faut dire que lui et moi partageons - je devrais dire partagions - le même avis sur la programmation fonctionnelle, notamment depuis nos déboires en OCaml (merci le programme de MPSI).
Donc, avec le plus grand respect je lui réponds :
Nani the fuck !?
À ma décharge je n'ai pas bu un seul verre d'alcool depuis la grossesse de ma petite dernière. Et oui. :P
Là il m'explique tout un tas de trucs et je dois dire que je me sens déstabilisée. Je m'explique, j'ai vécu trop de fois le fait de devoir maintenir du Scala pour savoir à quel point, sur le temps long, la programmation fonctionnelle pure devient encore moins maintenable que du Java + Spring Boot.
D'autant que je m'attendais à ce qu'il m'annonce qu'il allait (enfin) se monter un Shaarli.
Bref son idée est dirigée par les contraintes suivantes :
- Avoir un code dirigé par des contrats (au sens interfaces Java) dont le respect du typage est prouvé lors de la compilation et garanti lors de l'exécution.
- Ne pas avoir besoin de créer des instances techniques qui implémentent ces contrats, afin d'encapsuler des données dedans.
- Parvenir quand même à encapsuler les données pour les protéger mais sans jamais créer de nouvelles instances (cf. le point précédent).
- Avoir les mêmes performance d'exécution que du chaînage d'appels de fonctions
static
; et donc l'eldorado du zero GC-time. - Permettre une exécution différée/lazy/paresseuse.
Quand on lit le truc, on se dit que la vie n'est plus à un paradoxe prêt, mais pourtant sa façon de faire fonctionne.
Je vais essayer de vous décrire ce qu'il m'a expliqué afin de mieux l'ingérer moi-même.
Exemple : Déterminer si deux trucs sont égaux
Dans tous les cas de figure, nous allons supposer que les données sont décorées par une implémentation de l'interface suivante qui permet de les représenter sous la forme d'un tableau d'octets.
interface RawData {
fun asBytes():ByteArray
}
Version OOP Pure
1) On exprime le concept de vérification via une interface :
interface Check {
fun status():Boolean
}
Ensuite on fournit une implémentation de l'interface Check
qui soit spécialisée dans la comparaison de deux RawData
:
class IsEqual(
private val left:RawData,
private val right:RawData
):Check {
override fun status():Boolean {
return this.isSameInstances() || this.haveSameBytes()
}
private fun haveSameBytes():Boolean {
return left.asBytes().contentEquals(right.asBytes())
}
private fun isSameInstances():Boolean {
return left === right
}
}
Et ça s'utilise comme ceci :
val left:RawData = StrAsData("same data")
val right:RawData = StrAsData("same data")
val comparison:Boolean = IsEqual(left, right).status()
Problème
Pour comparer deux objets on doit forcément créer une instance de IsEqual
puis invoquer une méthode dessus. Sur JRE, c'est ~12% de temps de calcul supplémentaire par rapport à un simple appel de méthode statiques !
Version PF pure
Avant toute chose, il faut comprendre ce qu'est un contrat pour @Kysofer. C'est un truc qui va prouver à la compilation que les types d'entrée et de sortie soient bien respectés, ce qui fait que l'implémentation A pourra se substituer à l'implémentation B sans problème lors du runtime.
Mais comment peut-on produire l'équivalent d'une interface en PF ?
=> On passe par des fonctions de premier ordre.
En effet, une méthode qui reçoit une fonction de premier ordre ne sait pas ce qu'elle va exécuter. Elle sait juste que la fonction prendra un certain type en entrée et un certain type en retour. Le reste, c'est le compilateur qui agit.
Si l'on reprend l'interface Check
cela nous donne le prototype suivant :
val prototype:() -> Boolean
C'est-à-dire un Predicat
sans paramètre.
Maintenant passons à l'implémentation naïve, ceci nous donne :
fun isEqual(left:RawData, right:RawData):() -> Boolean {
return {
isSameInstances(left, right) || haveSameBytes(left, right)
}
}
private inline fun haveSameBytes(left:RawData, right:RawData):Boolean {
return left.asBytes().contentEquals(right.asBytes())
}
private inline fun isSameInstances(left:RawData, right:RawData):Boolean {
return left === right
}
On voit bien que la fonction isEqual(..)
retourne une fonction de premier ordre qui elle-même retournera un Boolean
.
Rappel
En Kotlin, les lambda sont déclarées entre deux accolades { .. }
.
Sauf que cet exemple a un problème puisqu'une lambda, sur JRE tout du moins, c'est une instance de l'interface Function
ou l'un de ses équivalents. Bref, il n'y a pas de gain en terme de performance par rapport la version OOP.
Pour l'obtenir, il suffit de remplacer la création d'une lambda par un pointeur sur une fonction anonyme, ce qui nous donne :
fun isEqual(left:RawData, right:RawData) = fun():Boolean {
return isSameInstances(left, right) || haveSameBytes(left, right)
}
Et la ça devient magique car la fonction anonyme étant écrite directement dans le byte-code et épinglée une et une seule fois en mémoire, cela revient à avoir un pointeur sur fonction qui exécutera la-dite fonction.
=> Soit un gain de 12% en termes de performance, ce qui fait qu'on rattrape la vitesse d'exécution d'un chaînage de méthodes statiques.
Mieux que cela, @Kysofer s'appuie sur une variante de la Curryfication . En effet, la méthode isEqual(..)
sert à encapsuler les données dans la pile d'appel pour les rendre utilisables que par la fonction anonyme. Donc il devient impossible de savoir ce qui sera utilisé par la lambda qui sera retournée.
=> Les paramètres left
et right
sont bien encapsulés dans la lambda !
Si on résume
- Le fait que la fonction
isEqual(..)
retourne une lambda et non un résultat fait que l'on va déclarer un contrat et donc rendre les lambda interchangeables. - Le fait que l'on retourne une fonction et non un résultat permet un calcul différé/paresseux potentiellement dans un autre thread.
- Le fait que l'on passe par une fonction anonyme qui utilisera les paramètres de la fonction déclarée fait qu'on encapsulera bien les données avant d'exécuter quoi que ce soit dessus.
- Le fait que tout soit
fonction
garanti l'absence de création d'instances techniques.
Nous avons donc bien là une version fonctionnelle de l'encapsulation et des contrats de l'OOP.
Ça aura pris 15 ans mais je pense être réconciliée totalement avec la PF (O_O) ; sauf le récursif, ça jamais ! En tout cas pas avant une démonstration comme celle-ci :P
Je me souviens que je disais en 2010 à @Roger quelque chose du type :
La programmation fonctionnelle est un cancer.
Heureusement, dix ans plus tard j'ai quand même un peu changé d'avis sur la question, je dirais aujourd'hui :
La programmation fonctionnelle "pure" est un cancer.
La différence étant que je suis en mesure d'argumenter le ressenti inconscient que j'avais à l'époque. Depuis le bouquin sur les design-patterns du Gang of Four, nous avons eu une pléthore d'autres auteurs qui nous ont expliqué pourquoi il faut toujours dépendre des interfaces et jamais des implémentations. Le problème avec ça, c'est qu'une grande partie des développeurs ne comprennent pas bien ce que sont les implémentations et surtout pourquoi il ne faut pas dépendre d'elles.
En réalité, une implémentation embarque avec elle des attributs (et si un objet n'a pas d'attribut c'est que le développeur a codé en procédurale "pure", il n'a rien encapsulé, ça n'est pas objet du tout mais je m'égare). Le problème avec les implémentations ce sont justement les attributs qui deviennent visibles. Dit autrement nous commençons à devenir dépendant de la structure de données qui nous arrive et non plus d'un ensemble de fonctions (ie. méthodes) que nous pouvons exécuter. D'ailleurs nous sommes tellement dépendant des attributs que même s'ils sont privés, nous allons alors ajouter des getters/setters pour y accéder quand même.
Remonter à l'interface c'est casser ce lien explicite avec la structure de données embarquée dans une classe et alors un changement de structure ou de structure de la structure n'impactera pas le code utilisateur.
Mais alors quel est le problème avec la programmation fonctionnelle "pure" ?
C'est justement qu'elle pousse tous les morceaux de code à dépendre des mêmes structures. Changez la structure à un endroit et vous êtes partis pour changer toutes les fonctions qui s'appuient sur cette structure. Cela engendre fondamentalement un maillage global de tous les pans de code d'une application avec un couplage fort autour de cette ou de ces structures.
La seule condition pour y remédier c'est d'avoir "la bonne structure du premier coup"... lol quoi... Comment prévoir quelles données (et de quels types) nous arriveront demain ?
À l'inverse, retourner des implémentations d'interface autoboxé dans le type de cette interface (c-à-d. les fameux "messages" de la POO) garanti non seulement que les changements de structures n'auront pas d'impact, mais que les changements d'algorithmes non plus (ici "pas d'impact" est à prendre au sens où le contrat d'interfaçage n'est pas rompu et donc que le code continu de compiler).
Pourquoi est-ce que je vous parle de tout ça ?
À cause Rust et de mes quatre semaines d'immersion intense.
Soyons clair, je trouve que les concepts derrière le langage sont incroyables et ses performances merveilleuses (en tant que dev Java j'ai toujours détesté la JVM rien que pour ce sujet). Par contre le fait que Rust se soit tourné exclusivement vers le fonctionnel et la programmation en "structure-first" à la place de celle en "contract-first" car la majorité des devs ne parviennent pas à penser en objets avec l'encapsulation, cela fait de Rust un langage aussi immaintenable que C mais un peu plus fiable grâce à son meilleur compilateur.
L'API de Rust est digne de celle du C. Par exemple, prenons la méthodes HashMap::keys()
de la stdlib de Rust. Celle-ci aurait pu retourner l'implémentation d'un trait Iterator
mais non, elle retourne une structure Key
qui contient une structureIter
qui contient une autre structure base::Iter
.
Changez un morceau de la chaîne et préparez-vous à gérer les impacts partout.
En résumé, et après être passée dans l'ordre par Java, OCaml, PHP, C, ASM, Bash, CSH, Python, JavaScript (ES5 à ES7), Anubis, Haskell, Ruby, Groovy, TypeScript, Scala, Go, Kotlin et enfin Rust (ndr. je bidouillais en Rust depuis quelques années), je peux vous assurer que :
- Rust est techniquement un super langage avec l'un des meilleurs compilateur du marché.
- Rust a une API aussi pourrie que celle de C, encourageant le couplage et augmentant l'immaintenabilité. Je crois qu'il doit exister un moyen d'outre-passer cela, mais je ne sais pas encore comment faire et ça me fruste pas mal.
Enfin, je sais que certains dev vont être fâchés de lire ce que j'écris alors permettez-moi de vous proposer un test car j'ai le sentiment que si c'est le cas, c'est que vous n'avez jamais pensé en OOP - et donc que vous ne pouvez pas encore comprendre ce que je dis. Il s'agit d'un exercice que @Kysofer a imaginé pour ses entretiens d'embauche afin de savoir si un candidat "expert Java" savait penser et programmer en orienté objet.
Prenez ces deux classes :
class Person {
private final String name;
private final String firstName;
private final int age;
public Person(String name, String firstName, int age) {
this.name = name;
this.firstName = firstName;
this.age = age;
}
}
class Car {
private final String brand;
private final String name;
public Car(String brand, String name) {
this.brand = brand;
this.name = name;
}
}
Objectifs :
- Sans violer l'encapsulation, c'est-à-dire sans jamais accéder aux attributs des deux classes depuis l'extérieur de ces deux classes.
- Sans ajouter des getter ou des setter.
- Sans mettre les attributs en public, package ou protected.
- Sans implémenter les algorithmes de conversion à l'intérieur des deux classes elles-mêmes.
=> Écrivez une architecture qui soit capable de convertir en JSON ou en XML ces deux objets.
Indice : Quand on pense en objet, c'est évident, très facile même. Quand on ne pense qu'en procédurale ou son évolution en fonctionnel, cela paraît impossible.
Et dans tout ça je ne compte pas arrêter Rust pour autant mais je fais appel à mes amis pour qu'ils m'aident à trouver une façon "clean" de coder dans ce langage.
Plein de petits algos pour se faciliter la vie entre POO et PF.
=> A adapter en Kotlin