Ви навчаєтесь, порівнюючи з тим, що вже знаєте. Нещодавно я постраждав від припущення, що Rust працює як Java щодо вирішення версій транзитивних залежностей. У цьому пості я хочу порівняти ці два підходи.
Перш ніж заглиблюватися в особливості кожного стеку, давайте опишемо домен і проблеми, які з ним пов'язані.
\ Під час розробки будь-якого проєкту вище рівня Hello World, ймовірно, ви зіткнетеся з проблемами, з якими інші стикалися раніше. Якщо проблема поширена, ймовірність висока, що хтось був достатньо добрим і відповідальним, щоб упакувати код, який її вирішує, для повторного використання іншими. Тепер ви можете використовувати пакет і зосередитися на вирішенні своєї основної проблеми. Так індустрія будує більшість проєктів сьогодні, навіть якщо це приносить інші проблеми: ви стоїте на плечах гігантів.
\ Мови програмування постачаються з інструментами збірки, які можуть додавати такі пакети до вашого проєкту. Більшість із них називають пакети, які ви додаєте до свого проєкту, залежностями. У свою чергу, залежності проєктів можуть мати власні залежності: останні називаються транзитивними залежностями.

На діаграмі вище C і D є транзитивними залежностями.
\ Транзитивні залежності мають власні проблеми. Найбільша з них виникає, коли транзитивна залежність потрібна з різних шляхів, але в різних версіях. На діаграмі нижче A і B обидва залежать від C, але від різних його версій.

Яку версію C повинен включити інструмент збірки у ваш проєкт? Java і Rust мають різні відповіді. Давайте опишемо їх по черзі.
Нагадування: код Java компілюється в байткод, який потім інтерпретується під час виконання (і іноді компілюється в нативний код, але це поза межами нашого поточного простору проблем). Спочатку я опишу вирішення залежностей під час виконання та вирішення залежностей під час збірки.
\ Під час виконання Java Virtual Machine пропонує концепцію classpath. Коли потрібно завантажити клас, середовище виконання шукає через налаштований classpath по порядку. Уявіть наступний клас:
public static Main { public static void main(String[] args) { Class.forName("ch.frankel.Dep"); } }
\ Давайте скомпілюємо його та виконаємо:
java -cp ./foo.jar:./bar.jar Main
\ Вищенаведене спочатку шукатиме в foo.jar клас ch.frankel.Dep. Якщо знайдено, він зупиняється там і завантажує клас, незалежно від того, чи він також присутній у bar.jar; якщо ні, він шукає далі в класі bar.jar. Якщо все ще не знайдено, він завершується з помилкою ClassNotFoundException.
\ Механізм вирішення залежностей Java під час виконання є впорядкованим і має поклассову гранулярність. Це застосовується незалежно від того, чи ви запускаєте клас Java і визначаєте classpath у командному рядку, як вище, чи запускаєте JAR, який визначає classpath у своєму маніфесті.
\ Давайте змінимо вищенаведений код на наступний:
public static Main { public static void main(String[] args) { var dep = new ch.frankel.Dep(); } }
\ Оскільки новий код безпосередньо посилається на Dep, новий код вимагає вирішення класу під час компіляції. Вирішення classpath працює так само:
javac -cp ./foo.jar:./bar.jar Main
\ Компілятор шукає Dep у foo.jar, потім у bar.jar, якщо не знайдено. Вищенаведене - це те, що ви вивчаєте на початку свого шляху вивчення Java.
\ Після цього вашою одиницею роботи є Java Archive, відомий як JAR, замість класу. JAR - це покращений ZIP-архів із внутрішнім маніфестом, який вказує його версію.
\ Тепер уявіть, що ви користувач foo.jar. Розробники foo.jar встановлюють певний classpath під час компіляції, можливо, включаючи інші JAR-файли. Вам знадобиться ця інформація для запуску власної команди. Як розробник бібліотеки передає ці знання користувачам нижче за течією?
\ Спільнота запропонувала кілька ідей для відповіді на це питання: Першою відповіддю, яка прижилася, був Maven. Maven має концепцію Project Object Model, де ви встановлюєте метадані свого проєкту, а також залежності. Maven може легко вирішувати транзитивні залежності, оскільки вони також публікують свій POM із власними залежностями. Отже, Maven може простежити залежності кожної залежності до листових залежностей.
\ Тепер повернемося до формулювання проблеми: як Maven вирішує конфлікти версій? Яку версію залежності Maven вирішить для C, 1.0 чи 2.0?
\ Документація чітка: найближчу.

На діаграмі вище шлях до v1 має відстань два, один до B, потім один до C; тим часом шлях до v2 має відстань три, один до A, потім один до D, і нарешті один до C. Таким чином, найкоротший шлях вказує на v1.
\ Однак на початковій діаграмі обидві версії C знаходяться на однаковій відстані від кореневого артефакту. Документація не дає відповіді. Якщо вас це цікавить, це залежить від порядку оголошення A і B у POM! Підсумовуючи, Maven повертає одну версію дубльованої залежності для включення її в classpath компіляції.
\ Якщо A може працювати з C v2.0 або B з C 1.0, чудово! Якщо ні, вам, ймовірно, доведеться оновити версію A або понизити версію B, щоб вирішена версія C працювала з обома. Це ручний процес, який болісний - запитайте мене, звідки я знаю. Гірше, ви можете виявити, що немає версії C, яка працює з обома A і B. Час замінити A або B.
Rust відрізняється від Java в кількох аспектах, але я думаю, що наступні є найбільш релевантними для нашої дискусії:
\ Давайте розглянемо їх по одному.
\ Java компілюється в байткод, потім ви запускаєте останній. Вам потрібно встановити classpath як під час компіляції, так і під час виконання. Компіляція з певним classpath і запуск з іншим може призвести до помилок. Наприклад, уявіть, що ви компілюєте з класом, від якого залежите, але клас відсутній під час виконання. Або, альтернативно, він присутній, але в несумісній версії.
\ На відміну від цього модульного підходу, Rust компілює в унікальний нативний пакет код крейту та кожну залежність. Крім того, Rust надає власний інструмент збірки, таким чином уникаючи необхідності пам'ятати особливості різних інструментів. Я згадував Maven, але інші інструменти збірки, ймовірно, мають різні правила для вирішення версії у вищенаведеному випадку використання.
\ Нарешті, Java вирішує залежності з бінарних файлів: JAR-файлів. Навпаки, Rust вирішує залежності з вихідного коду. Під час збірки Cargo вирішує все дерево залежностей, завантажує всі необхідні вихідні коди та компілює їх у правильному порядку.
\ З урахуванням цього, як Rust вирішує версію залежності C у початковій проблемі? Відповідь може здатися дивною, якщо ви з Java-фону, але Rust включає обидві. Дійсно, на діаграмі вище Rust скомпілює A з C v1.0 і скомпілює B з C v2.0. Проблема вирішена.
Мови JVM, і Java зокрема, пропонують як classpath під час компіляції, так і classpath під час виконання. Це дозволяє модульність і повторне використання, але відкриває двері для проблем щодо вирішення classpath. З іншого боку, Rust будує ваш крейт в один самодостатній бінарний файл, будь то бібліотека чи виконуваний файл.
\ Щоб дізнатися більше:
Спочатку опубліковано на A Java Geek 14 вересня 2025


