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