On apprend en comparant avec ce que l'on connaît déjà. J'ai récemment été pris au dépourvu en supposant que Rust fonctionnait comme Java concernant la résolution des versions de dépendances transitives. Dans cet article, je souhaite comparer les deux.
Avant de plonger dans les spécificités de chaque stack, décrivons le domaine et les problèmes qui l'accompagnent.
\ Lors du développement de tout projet au-delà du niveau Hello World, il y a de fortes chances que vous rencontriez des problèmes que d'autres ont déjà rencontrés. Si le problème est répandu, la probabilité est élevée que quelqu'un ait été assez aimable et civique pour avoir empaqueté le code qui le résout, afin que d'autres puissent le réutiliser. Maintenant, vous pouvez utiliser le package et vous concentrer sur la résolution de votre problème principal. C'est ainsi que l'industrie construit la plupart des projets aujourd'hui, même si cela apporte d'autres problèmes : vous vous tenez sur les épaules des géants.
\ Les langages sont accompagnés d'outils de build qui peuvent ajouter de tels packages à votre projet. La plupart d'entre eux font référence aux packages que vous ajoutez à votre projet comme des dépendances. À leur tour, les dépendances des projets peuvent avoir leurs propres dépendances : ces dernières sont appelées dépendances transitives.

Dans le diagramme ci-dessus, C et D sont des dépendances transitives.
\ Les dépendances transitives ont leurs propres problèmes. Le plus important est lorsqu'une dépendance transitive est requise à partir de différents chemins, mais dans différentes versions. Dans le diagramme ci-dessous, A et B dépendent tous deux de C, mais de différentes versions.

Quelle version de C l'outil de build devrait-il inclure dans votre projet ? Java et Rust ont des réponses différentes. Décrivons-les tour à tour.
Rappel : le code Java se compile en bytecode, qui est ensuite interprété à l'exécution (et parfois compilé en code natif, mais cela sort du cadre de notre problème actuel). Je vais d'abord décrire la résolution des dépendances à l'exécution et la résolution des dépendances au moment de la compilation.
\ À l'exécution, la Machine Virtuelle Java offre le concept de classpath. Lorsqu'il faut charger une classe, l'environnement d'exécution recherche dans le classpath configuré dans l'ordre. Imaginez la classe suivante :
public static Main { public static void main(String[] args) { Class.forName("ch.frankel.Dep"); } }
\ Compilons-la et exécutons-la :
java -cp ./foo.jar:./bar.jar Main
\ Ce qui précède cherchera d'abord dans le foo.jar la classe ch.frankel.Dep. Si elle est trouvée, il s'arrête là et charge la classe, indépendamment du fait qu'elle puisse également être présente dans le bar.jar ; sinon, il cherche plus loin dans la classe bar.jar. Si elle n'est toujours pas trouvée, il échoue avec une ClassNotFoundException.
\ Le mécanisme de résolution des dépendances à l'exécution de Java est ordonné et a une granularité par classe. Il s'applique que vous exécutiez une classe Java et définissiez le classpath sur la ligne de commande comme ci-dessus, ou que vous exécutiez un JAR qui définit le classpath dans son manifeste.
\ Modifions le code ci-dessus comme suit :
public static Main { public static void main(String[] args) { var dep = new ch.frankel.Dep(); } }
\ Parce que le nouveau code fait référence directement à Dep, le nouveau code nécessite une résolution de classe au moment de la compilation. La résolution du classpath fonctionne de la même manière :
javac -cp ./foo.jar:./bar.jar Main
\ Le compilateur cherche Dep dans foo.jar, puis dans bar.jar s'il ne le trouve pas. Ce qui précède est ce que vous apprenez au début de votre parcours d'apprentissage de Java.
\ Par la suite, votre unité de travail est l'Archive Java, connue sous le nom de JAR, au lieu de la classe. Un JAR est une archive ZIP glorifiée, avec un manifeste interne qui spécifie sa version.
\ Maintenant, imaginez que vous êtes un utilisateur de foo.jar. Les développeurs de foo.jar ont défini un classpath spécifique lors de la compilation, incluant éventuellement d'autres JAR. Vous aurez besoin de cette information pour exécuter votre propre commande. Comment un développeur de bibliothèque transmet-il cette connaissance aux utilisateurs en aval ?
\ La communauté a proposé quelques idées pour répondre à cette question : La première réponse qui a persisté était Maven. Maven a le concept de Project Object Model, où vous définissez les métadonnées de votre projet, ainsi que les dépendances. Maven peut facilement résoudre les dépendances transitives car elles publient également leur POM, avec leurs propres dépendances. Par conséquent, Maven peut tracer les dépendances de chaque dépendance jusqu'aux dépendances feuilles.
\ Maintenant, revenons à l'énoncé du problème : comment Maven résout-il les conflits de version ? Quelle version de dépendance Maven résoudra-t-il pour C, 1.0 ou 2.0 ?
\ La documentation est claire : la plus proche.

Dans le diagramme ci-dessus, le chemin vers v1 a une distance de deux, un vers B, puis un vers C ; pendant ce temps, le chemin vers v2 a une distance de trois, un vers A, puis un vers D, puis enfin un vers C. Ainsi, le chemin le plus court pointe vers v1.
\ Cependant, dans le diagramme initial, les deux versions de C sont à la même distance de l'artefact racine. La documentation ne fournit pas de réponse. Si cela vous intéresse, cela dépend de l'ordre de déclaration de A et B dans le POM ! En résumé, Maven renvoie une seule version d'une dépendance dupliquée pour l'inclure dans le classpath de compilation.
\ Si A peut fonctionner avec C v2.0 ou B avec C 1.0, parfait ! Sinon, vous devrez probablement mettre à niveau votre version de A ou rétrograder votre version de B, afin que la version résolue de C fonctionne avec les deux. C'est un processus manuel qui est douloureux - demandez-moi comment je le sais. Pire encore, vous pourriez découvrir qu'il n'existe aucune version de C qui fonctionne à la fois avec A et B. Il est temps de remplacer A ou B.
Rust diffère de Java à plusieurs égards, mais je pense que les suivants sont les plus pertinents pour notre discussion :
\ Examinons-les un par un.
\ Java se compile en bytecode, puis vous exécutez ce dernier. Vous devez définir le classpath à la fois au moment de la compilation et à l'exécution. Compiler avec un classpath spécifique et exécuter avec un autre peut conduire à des erreurs. Par exemple, imaginez que vous compilez avec une classe dont vous dépendez, mais la classe est absente à l'exécution. Ou alternativement, elle est présente, mais dans une version incompatible.
\ Contrairement à cette approche modulaire, Rust compile en un package natif unique le code du crate et chaque dépendance. De plus, Rust fournit également son propre outil de build, évitant ainsi d'avoir à se souvenir des particularités des différents outils. J'ai mentionné Maven, mais d'autres outils de build ont probablement des règles différentes pour résoudre la version dans le cas d'utilisation ci-dessus.
\ Enfin, Java résout les dépendances à partir des binaires : les JAR. Au contraire, Rust résout les dépendances à partir des sources. Au moment de la compilation, Cargo résout l'ensemble de l'arbre de dépendances, télécharge toutes les sources requises et les compile dans le bon ordre.
\ Avec cela à l'esprit, comment Rust résout-il la version de la dépendance C dans le problème initial ? La réponse peut sembler étrange si vous venez d'un contexte Java, mais Rust inclut les deux. En effet, dans le diagramme ci-dessus, Rust compilera A avec C v1.0 et compilera B avec C v2.0. Problème résolu.
Les langages JVM, et Java en particulier, offrent à la fois un classpath au moment de la compilation et un classpath à l'exécution. Cela permet la modularité et la réutilisabilité, mais ouvre la porte à des problèmes concernant la résolution du classpath. D'autre part, Rust construit votre crate en un seul binaire autonome, qu'il s'agisse d'une bibliothèque ou d'un exécutable.
\ Pour aller plus loin :
Publié initialement sur A Java Geek le 14 septembre 2025


