Как стать автором
Обновить

Два подхода к интерпретации видимости в Rust

Время на прочтение12 мин
Количество просмотров1.1K
Автор оригинала: Jakub Beránek

В прошлом году, просматривая пул-реквесты по поводу компилятора Rust, я обратил внимание на #126013. В нём к некоторым пакетам компилятора добавлялась проверка unreachable_pub. Естественно, меня это заинтересовало, так как на тот момент я о такой проверке не знал. Но, разобравшись с её описанием, я тем более удивился, так как эта проверка показалась мне абсолютным нонсенсом! Поговорив об этом с авторами пул-реквеста, я осознал, что, пожалуй, достаточно странно представляю себе, как устроена видимость в Rust. Как минимум, я воспринимал её не «так, как она была задумана».

Эта тема показалась мне достаточно интересной, чтобы раскрыть её в блоге. В этой статье я коротко объясню, как именно работает видимость в Rust, а потом опишу два достаточно разных способа её использовать. Если вы знаете, как в Rust устроена видимость, можете смело пропускать введение и переходить к главной теме. Оговорюсь, что в этом посте я просто вывалил различные мысли на данную тему, скопившиеся у меня, так что не ожидайте найти здесь каких-либо супер-откровений :).

Видимость в Rust

Любой элемент в Rust (например, функция, структура, перечисление, т.д.) обладает некоторой видимостью. От видимости зависит, какой другой код может пользоваться этим элементом. По умолчанию элемент приватный, то есть он доступен только в рамках того модуля, где определён, а также в любых дочерних модулях (любого порядка). Однако к этому элементу нельзя обратиться из «вышестоящих» модулей той же иерархии, в том числе из родительских модулей и из других модулей того же поколения, что и рассматриваемый.

mod a {
  // Приватная структура
  struct Foo;

  mod c {
    // Здесь можно обратиться к`Foo` (напр., через `super::Foo`)
  }
  // Здесь доступна `Foo`
}
mod b {
  // `Foo` здесь НЕДОСТУПНА
}
// `Foo` здесь НЕДОСТУПНА

Можно переопределить настройки видимости, заданные по умолчанию. Это делается при помощи ключевого слова pub, которое по желанию можно расширить, задав путь, ограничивающий область видимости. Например, pub(crate) или pub(in super::super).

Здесь принципиально важно, что pub одновременно делает две разные вещи:

Экспортирует элемент, так что он становится доступен не только из актуального модуля и его дочерних модулей, но и из набора предковых модулей. При использовании pub(self), что, в сущности, равноценно приватной видимости, этот набор будет пуст, что обычно не слишком полезно. Но, чтобы элемент на самом деле был доступен по некоторому пути, все модули на этом пути также обязаны быть доступны:

mod a {
  mod b {
    mod c {
      // Открываем доступ к `Foo` для `b` и `a`
      pub(in super::super) struct Foo;      
    }
  
    fn foo(_: c::Foo) {} // OK
  
    pub(super) use c::Foo;
  }
  // Ошибка, доступ к `Foo` через `c` закрыт, так как `c` до сих пор остаётся приватным 
  fn bar(_: b::c::Foo) {}
    
  // OK, `Foo` доступен из `b` через реэкспорт
  fn baz(_: b::Foo) {}
}

Так ограничивается область видимости, в которой можно продолжать реэкспорт элемента. Это важно только в случае, когда pub используется в сочетании с каким-нибудь путём, поскольку, когда pub применяется сам по себе, элемент поддаётся произвольному реэкспорту, даже вне текущего пакета. Например:

mod a {
  mod b {
    pub(super) mod c {
      // Открываем доступ к `Foo` для `b` и `a`
      pub(in super::super) struct Foo;
    }

    pub(super) use c::Foo; // OK, реэкспорт в `a`, где `Foo` доступен
  }

  fn foo(_: b::c::Foo) {} // OK

  pub(super) use b::c::Foo; // Ошибка, дальнейший реэкспорт `Foo` невозможен
}

Эта возможность, как правило, используется через pub(crate), чтобы элемент можно было задействовать только в пределах пакета, но нельзя было реэкспортировать за его пределы.

На мой взгляд, из-за двух столь разных аспектов видимости в Rust возникает некоторое напряжение, так как их нельзя сконфигурировать в отдельности друг от друга (ниже в статье я остановлюсь на этом подробнее). Теперь, когда я помог вам составить общее впечатление о видимости в Rust, расскажу о тех подходах к применению этой фичи, с которыми мне приходилось сталкиваться. Сначала кратко опишу оба варианта видимости, а потом сравню, в чём они выигрышны и проигрышны относительно друг друга.

Глобальная видимость

Начну с того способа работы с видимостью, который, на мой взгляд, действует по умолчанию. В таком случае вы указываете «окончательную» видимость, которую хотите придать данному элементу, и делаете это конкретно на данном элементе. Например, если вы хотите экспортировать элемент из его пакета, пометьте этот элемент pub. Если вы хотите всего лишь предоставить доступ к нему в пределах пакета, пометьте его pub(crate).

Будем называть такой подход «глобальным», поскольку, в сущности, приходится решать, где именно элемент должен быть глобально доступен в пределах своего пакета (или даже целого графа пакетов), и такое решение принимается конкретно на данном элементе. Иными словами, если вы напишете fn <Foo>, то (теоретически) предполагается, что вы продумали все те места, в которых будет доступен Foo. Разумеется, если вы до сих пор читали внимательно, то понимаете, что это совсем не просто. Подробнее поговорим об этом ниже.

Локальная видимость

Лично мне ближе второй подход — именно так я работаю с видимостью в Rust. Я практически не пользуюсь pub(crate)pub(super) или pub(in ...), а задействую pub лишь как двоичный модификатор, определяющий, является ли этот элемент приватным, либо экспортируется в свой же родительский модуль. При условии, что эти модификаторы видимости уже используются в базе кода, я всегда стараюсь придерживаться сложившегося стиля. Но по возможности я пользуюсь pub в качестве ключевого слова export. Такой подход значительно ближе к тому, как принято поступать с видимостью в других мейнстримовых языках. Например, вспомните ключевые слова export в JavaScript/TypeScript, public в C#/Java или static в C. Насчёт С следует оговориться, что в этом языке по умолчанию принято прямо противоположное: весь код публичный, а ключевое слово static, употребляемое с нелокальными переменными и функциями, делает их приватными в рамках данной единицы трансляции. Кроме того, в C нет полноценной системы модулей, есть только режимы связывания. А применительно к переменным с автоматически задаваемым сроком хранения static работает совсем иначе, но, в общем, идею вы поняли. 

Я называю такой подход локальным, так как только от самого элемента зависит его локальная видимость, т.е., экспортируется он в свой родительский модуль или нет. Тогда именно модуль-предок (или несколько предков) отвечает за то, как данный модуль будет виден в пределах пакета, и будет ли он реэкспортироваться из данного пакета во внешний мир.

Вот чего, в сущности, я пытаюсь здесь добиться: использовать pub(super) для обеспечения «доступа», а  pub для обеспечения «реэкспорта». Иными словами, мне нужно, чтобы родителю был открыт доступ к элементу, и после этого родитель мог (произвольно) решать, как и куда этот элемент экспортировать. Но, поскольку (насколько мне известно) разграничить две эти концепции в Rust не так просто, будем придерживаться pub, чтобы код получался короче.

Ниже показано, как можно использовать локальную видимость:

mod service {
  mod scheduler {
    // Пригодится везде, пусть родитель решает, куда его реэкспортировать и где использовать 
    pub struct Scheduler;

    fn estimate_cost() {
      // используем вспомогательную функцию, предоставляемую родительским модулем 
      super::utils::calculate_graph_cost();
    }
  }

  // Доступно только в этом модуле, не реэкспортируется
  mod utils {
    // Пригодится за пределами модуля, пусть родитель решает, куда его реэкспортировать и где использовать
    pub fn calculate_graph_cost() {}
  }

  // Реэкспортируется вверх по иерархии
  pub use scheduler::Scheduler;
}

Обратите внимание, как calculate_graph_cost сопровождается отметкой pub, пусть я и решительно не намерен экспортировать её за пределы актуального пакета — всё-таки это просто вспомогательная функция. Пожалуй, если бы я хотел быть на 100% уверен, что элементы из модуля utils ни в коем случае не пойдут в ходвыше в иерархии модулей, я мог бы пометить их как pub(super). Но я не вижу в этом особой пользы, поскольку это осложнило бы мою довольно простую эвристику (pub — экспорт в родительский модуль, нет pub — приватный). Именно родителю решать, реэкспортировать ли эти элементы куда-либо.

Сравнение

На первый взгляд, два этих подхода могут показаться почти идентичными. Конечно, вы можете счесть, что мне просто слишком лениво как следует прописывать все нужные модификаторы pub(<path>), но я обещаю, что постараюсь раскрыть эти вопросы в оставшейся части статьи.

Действительно, в результате два этих подхода могут давать очень похожий код. На мой взгляд, в данном случае важно поговорить о том, как именно мне видится присваивание видимости отдельным элементам. Это влияет не только на то, как я структурирую код, и это также имеет несколько интересных эффектов и требует компромиссов, которые я попробую описать ниже.

Разумеется, нужно отметить, что возможны и другие подходы к использованию видимости в Rust и соответствующие паттерны. Кроме того, можно и сочетать сразу несколько подходов. Ради сравнения я соотнесу здесь достоинства и недостатки двух подходов, которые я описал выше, а также расскажу, как они вписываются в различные способы структурирования кода и позволяют использовать сильные стороны этих подходов.   

Простота

Под «простотой» я понимаю характеристику, позволяющую мне определить, какова должна быть видимость конкретного элемента. Если мы решаем задачу с точки зрения локальной видимости, то для отдельного элемента важно лишь, приватен ли он (обычно потому, что в рамках элемента должны соблюдаться какие-то внутренние инварианты или потому, что использовать этот элемент где-то ещё просто не имеет смысла). Также при этом решается, можно ли экспортировать функциональность данного модуля в (любой) другой модуль. В таком случае принимается решение по принципу «или-или», и при этом оно является достаточно простым. Мне больше почти ничего не приходится учитывать, и мне это нравится. Кроме того, это хорошо сочетается с позицией, что по умолчанию все элементы должны быть приватными. Те или иные сущности могут делаться pub только по требованию (этого я обычно добиваюсь, внося минимальные правки в IDE), когда мне требуется использовать элемент ещё где-то в пределах пакета.   

Когда приходится добиваться глобальной видимости, мне представляется, как будто я должен продумать, как соотносится видимость элементов друг для друга в пределах всего пакета. Если бы я работал с pub(crate), то должен был бы отдельно решать для каждого неприватного элемента (даже для такого, который имеет пять уровней вложенности в глубину), должен ли он быть доступен за пределами актуального пакета. Я не вижу в этом особого смысла; думаю, что такое решение должно не приниматься на уровне самого элемента, а зависеть от его модулей-предков.

Конечно же, никому не нравится подолгу размышлять о глобальной видимости каждого элемента, который требуется реализовать, но мне всё равно кажется, что проще всего давать возможность выбирать по принципу «или-или».

Компонуемость

Ещё одно приятное свойство локальной видимости заключается в том, что при таком подходе отдельные решения о предоставлении видимости можно компоновать. Для каждого отдельного элемента индивидуально решается, будет он экспортироваться или нет. Далее родительский модуль рекурсивно применяет одно и то же решение, которое было принято локально. Будет ли конкретный элемент (модуль) полезен лишь там, где находится сейчас, либо стоит экспортировать его вверх по иерархии модулей? Тогда окончательная ситуация с видимостью — это результат компоновки всех решений, принятых предковыми модулями данного элемента.

Локальный аспект такого подхода также означает, что в пределах пакета можно перенести куда-либо целый набор модулей. С тем же успехом его можно переносить и из пакета в пакет, и при этом модификаторы видимости модулей должны продолжать действовать и в пределах перемещённых модулей, независимо от того, какой другой код находится в новом пакете.

Верно и обратное: такие модификаторы как pub(crate) (или pub, когда он интерпретируется как «доступен вне актуального пакета») в некотором роде «нарушают границы» родительских модулей — мне это кажется странным. Компилятор, обрабатывая показатели видимости, также учитывает иерархию. Поэтому, если родительский модуль элемента pub(crate) не реэкспортирует его, то, фактически, видимость этого элемента не соответствует pub(crate) — как мне кажется, здесь можно запутаться. Правда, для таких случаев существуют предупреждения (lints), и здесь мы подходим к следующему разделу.

Предупреждения

Выше я показал, что при глобальном подходе к видимости бывает не так просто задействовать pub(crate) или pub, если в родительских модулях как следует не налажен реэкспорт конкретного элемента. Чтобы обнаруживать такие ситуации, можно пользоваться двумя предупреждениями:

  • Предупреждение (компилятора) unreachable_pub позволяет гарантировать, что каждый элемент pub на практике будет виден за пределами своего пакета.

  • Предупреждение redundant_pub_crate (от Clippy) позволяет гарантировать, что любой элемент с pub(crate) на практике будет виден в пределах всего пакета.

Само существование таких предупреждений подсказывает, что по умолчанию видимость в Rust интерпретируется именно в соответствии с «глобальным» подходом. Но это также означает, что в этом подходе есть некоторые изъяны — ведь им можно пользоваться лишь при наличии предупреждений, гарантирующих согласованную интерпретацию видимости.

При локальной видимости особого смысла в предупреждении unreachable_pub нет. Ведь даже такие элементы, которые видимы, например, только своему родительскому модулю, либо в пределах пакета, будут помечаться как pub. Вот почему я поначалу не понимал, как работать с этим предупреждением.

Поддержка предупреждений — это в целом большой минус подхода с локальной видимостью. Дело в том, что я пока не нашёл такого инструментария, с которым этот подход был бы удобен. Кстати, после рефакторинга может случиться так, что некоторые экспортированные элементы более не будут использоваться где-либо за пределами своего модуля, поэтому степень видимости можно понизить до приватной. Хорошо было бы иметь такое предупреждение, которое выявляло бы такие случаи. Не думаю, что подобное предупреждение уже существует; может быть, потребовался бы такой глобальный анализ, который в Clippy в настоящее время не поддерживается (?), но не менее вероятно, что такую возможность просто пока никто не пытался реализовать.

При этом, нет ничего страшного в том, чтобы сопровождать модификатором pub некоторые дополнительные элементы. Ведь, если при проектировании вашего публичного интерфейса вы придерживаетесь некоторого стиля (что я затрону ниже), нужно исключить всякую возможность неожиданной утечки из этих элементов за пределы пакета (вот это уже будет проблема).

Определимся с внешней видимостью

В качестве аргумента против использования локальной видимости мне доводилось слышать, что при таком подходе невозможно сразу же судить о «внешней видимости» элемента. Иными словами, вы не знаете, будет ли конкретный элемент экспортироваться из пакета. При этом мало будет просто заглянуть в его объявление, так как там будет указано лишь то, может ли он экспортироваться в родительский элемент.

Действительно, глобальная видимость в данном отношении выглядит выигрышнее, поскольку позволяет рассмотреть элемент в отдельности от других, а также сразу увидеть, доступен ли он вне текущего пакета. Правда, это соблюдается лишь при использовании вышеупомянутых предупреждений — без них информация о видимости может быть неточна!  

При всём вышесказанном лично мне редко приходится задаваться таким вопросом — но, вероятно, дело в том, что я не так часто имею дело с разработкой библиотек. Думаю, именно такая функциональность должна быть реализована на уровне IDE (например, RustRover или Rust Analyzer). Она должна давать вам ответы на такие вопросы как «доступен ли этот элемент вне данного пакета?» просто после того, как вы наведёте курсор на этот элемент.

Проектирование публичного интерфейса пакета

Один из наиболее интересных аспектов, постоянно перекликающихся с видимостью – это порядок определения публичного интерфейса пакета. Вероятно, существует немало способов спроектировать такой интерфейс, но я хотел бы описать здесь конкретные детали выстраивания публичных API, связанные именно с тем, какой именно подход к видимости вы используете.

Если вы практикуете глобальную видимость, то каждый элемент сам решает, должен ли он экспортироваться из конкретного пакета. Вы можете легко с этим справиться, сделав ваши промежуточные модули pub или применив глобальные реэкспорты (pub use foo::*;), тем самым просачивая видимость всех вложенных элементов до самого корня пакета. В таком случае даже корневые модули вы сделаете публичными:

pub mod foo {
  pub mod bar {
    pub struct A;
    struct B;
  }
}

// Внешние пакеты могут обращаться к foo::bar::A, но не к foo::bar::B

Вот в чём преимущество такого подхода: если вам предстоит экспортировать множество элементов, то не придётся вручную составлять из них список в корне пакета только для того, чтобы выполнить такой экспорт. В сущности, это означает, что вы «скопом реэкспортируете» из пакета все модули и те их дочерние модули, в которых содержится хотя бы один pub-элемент.

Такой подход удобен, но и у него есть недостатки. Те элементы, которые экспортируются таким способом, автоматически станут доступны в структуре модулей, используемой в вашем пакете. Поэтому, если вы хотите экспортировать их по каким-то другим путям, то понадобится другой подход. Кроме того, ваш публичный интерфейс оказывается рассеян по всему пакету, его не получится сразу увидеть целиком в исходном коде. В таком случае приходится прибегать к таким инструментам как cargo-public-api, которые позволяют вывести ваш публичный интерфейс по результатам проверки пакета. Точно как и при уточнении видимости отдельных элементов вне пакета, полагаю, что в идеале такая информация должна отображаться на уровне IDE.

При работе с локальной видимостью я обычно использую схожий метод, хотя и не могу сделать корневые модули pub. Дело в том, что тогда я мог бы нечаянно экспортировать вложенные pub-элементы, которые экспортировать нельзя. В данном случае было бы более естественно по отдельности выбирать и экспортировать именно те элементы, из которых слагается ваш публичный API:

// src/lib.rs
// Эти модули остаются приватными, здесь никаких "pub mod".
mod comm;
mod gateway;
mod program;

// Здесь я собираю публичный API библиотеки,
// в таком случае именно здесь я буду полностью его контролировать.
pub use comm::Foo;
pub use gateway::Bar;

Действуя таким образом вручную, я полностью контролирую весь публичный интерфейс. Я даже могу выстроить «виртуальную» структуру экспортированных модулей, которая может совсем не соответствовать реальной структуре моего пакета:

mod comm;
mod gateway;
mod program;

pub mod client {
  pub use program::Baz;

  pub mod api {
    pub use gateway::Bar;
  }
}

Отдельный плюс в том, что весь внешний интерфейс вашего пакета виден в одном месте, и никакие дополнительные инструменты вам не нужны.

Недостаток, разумеется, в том, что поверхность API велика, и корень библиотеки также может стать очень велик. В таком случае можно делегировать в дочерние модули решение, экспортировать что-либо или нет из корневого модуля. Затем из этих дочерних модулей можно реэкспортировать всё, что нужно.

Заключение

Вот, собственно, и всё. Как я и говорил с самого начала, этот пост — просто набор мыслей, которые я хотел артикулировать, без какого-либо грандиозного вывода. В конце концов, каждый сам интерпретирует видимость в Rust, и это зависит от того, какой стиль вы предпочитаете. Лично я предпочитаю локальный подход к видимости, мне о нём проще рассуждать. Но, может быть, я просто не сталкивался на практике с такими случаями, в которых гораздо предпочтительнее был бы другой подход?

Какой подход больше нравится вам? Знаете ли вы другие подходы к интерпретации видимости в Rust?

Теги:
Хабы:
+8
Комментарии0

Публикации

Работа

Ближайшие события