Модули
Модули в Julia помогают организовать код в связные единицы. Они разделяются синтаксически внутри module NameOfModule ... end, и обладают следующими особенностями:
Модули являются отдельными именованными пространствами, каждый из которых вводит новую глобальную область видимости. Это полезно, потому что позволяет использовать одно и то же имя для различных функций или глобальных переменных без конфликтов, если они находятся в разных модулях.
Модули имеют средства для управления именованными пространствами: каждый определяет набор имен, которые он
exportет, и может импортировать имена из других модулей с помощьюusingиimport(мы объясним эти пункты ниже).Модули могут быть предварительно скомпилированы для более быстрого загрузки и содержат код для инициализации во время выполнения.
Как правило, в больших пакетах Julia вы увидите модульный код, организованный в файлы, например
module SomeModule
# export, using, import statements are usually here; we discuss these below
include("file1.jl")
include("file2.jl")
end
Файлы и имена файлов в основном не связаны с модулями; модули связаны только с выражениями модулей. Можно иметь несколько файлов на один модуль и несколько модулей в одном файле. include ведет себя так, как будто содержимое исходного файла оценивается в глобальной области видимости включаемого модуля. В этой главе мы используем короткие и упрощенные примеры, поэтому мы не будем использовать include.
Рекомендуемый стиль — не отступать тело модуля, так как это обычно приводит к отступам целых файлов. Также принято использовать UpperCamelCase для имён модулей (как и для типов), и использовать множественное число, если применимо, особенно если модуль содержит идентификатор с аналогичным именем, чтобы избежать конфликтов имён. Например,
module FastThings
struct FastThing
...
end
end
Управление именованным пространством
Управление именованным пространством относится к средствам языка, предоставляющим доступ к именам в одном модуле из другого. Мы подробно обсудим связанные с этим концепции и функциональные возможности ниже.
Квалифицированные имена
Имена функций, переменных и типов в глобальной области видимости, например, sin, ARGS, и UnitRange всегда принадлежат модулю, называемому родительским модулем, который можно найти интерактивно с помощью parentmodule, например
julia> parentmodule(UnitRange) Base
Можно также ссылаться на эти имена за пределами их родительского модуля, предваряя их именем модуля, например Base.UnitRange. Это называется квалифицированным именем. Родительский модуль может быть доступен с помощью цепочки подмодулей, например Base.Math.sin, где Base.Math называется путь модуля. Из-за синтаксических неоднозначностей для квалификации имени, содержащего только символы, такие как оператор, необходимо вставить двоеточие, например Base.:+. Небольшое количество операторов дополнительно требует скобок, например Base.:(==).
Если имя квалифицировано, то оно всегда доступно, а в случае с функцией ему также можно добавить методы, используя квалифицированное имя как имя функции.
Внутри модуля имя переменной может быть «зарезервировано» без присваивания, объявив его как global x. Это предотвращает конфликты имён для глобальных переменных, инициализированных после загрузки. Синтаксис M.x = y не работает для присваивания глобальной переменной в другом модуле; глобальное присваивание всегда локально для модуля.
Списки экспорта
Имена (относительно функций, типов, глобальных переменных и констант) можно добавить в список экспорта модуля с помощью export. Как правило, они находятся в верхней части определения модуля, чтобы читатели исходного кода могли легко их найти, как в
module NiceStuff export nice, DOG struct Dog end # singleton type, not exported const DOG = Dog() # named instance, exported nice(x) = "nice $x" # function, exported end
но это лишь рекомендация по стилю — модуль может иметь несколько операторов export в произвольных местах.
Обычно экспортируются имена, которые являются частью API (интерфейса программирования приложения). В приведенном выше коде список экспорта предполагает, что пользователи должны использовать nice и DOG . Однако, поскольку квалифицированные имена всегда делают идентификаторы доступными, это просто вариант организации API: в отличие от других языков, Julia не имеет средств для реального скрытия внутренних деталей модуля.
Также некоторые модули вообще не экспортируют имена. Это обычно делается, если они используют общие слова, такие как derivative, в своём API, которые легко могут столкнуться со списками экспорта других модулей. Мы увидим, как управлять конфликтами имён ниже.
Самостоятельные using и import
Возможно, наиболее распространённый способ загрузки модуля — using ModuleName. Это загружает код, связанный с ModuleName, и приносит
имя модуля
и элементы списка экспорта в окружающее глобальное именованное пространство.
Технически, оператор using ModuleName означает, что модуль под названием ModuleName будет доступен для разрешения имён по мере необходимости. Когда встречается глобальная переменная, у которой нет определения в текущем модуле, система будет искать её среди переменных, экспортированных ModuleName, и использовать её, если она найдена там. Это означает, что все использования этой глобальной переменной внутри текущего модуля будут перенаправлены на определение этой переменной в ModuleName.
Продолжая наш пример,
using NiceStuff
загрузит вышеприведённый код, сделав NiceStuff (имя модуля), DOG и nice доступными. Dog отсутствует в списке экспорта, но к нему можно получить доступ, если имя квалифицировано с помощью пути модуля (который здесь — просто имя модуля), как NiceStuff.Dog.
Важно, что using ModuleName — единственная форма, для которой списки экспорта вообще имеют значение.
В отличие от этого,
import NiceStuff
приводит только имя модуля в область видимости. Пользователям потребуется использовать NiceStuff.DOG, NiceStuff.Dog, и NiceStuff.nice для доступа к его содержимому. Обычно import ModuleName используется в контекстах, когда пользователь хочет сохранить именованное пространство чистым. Как мы увидим в следующем разделе, import NiceStuff эквивалентно using NiceStuff: NiceStuff.
Можно комбинировать несколько операторов using и import одного типа в выражении через запятую, например
using LinearAlgebra, Statistics
using и import со специфическими идентификаторами и добавлением методов
Когда using ModuleName: или import ModuleName: последует за перечислением имён через запятую, модуль загружается, но в именованное пространство вводятся только эти конкретные имена оператором. Например,
using NiceStuff: nice, DOG
импортирует имена nice и DOG.
Важно, что имя модуля NiceStuff не будет находиться в именованном пространстве. Если вы хотите сделать его доступным, вам нужно явно указать его, как
using NiceStuff: nice, DOG, NiceStuff
Julia имеет две формы для, казалось бы, одного и того же, потому что только import ModuleName: f позволяет добавлять методы к f без пути модуля. То есть, следующий пример выдаст ошибку:
using NiceStuff: nice struct Cat end nice(::Cat) = "nice 😸"
Эта ошибка предотвращает случайное добавление методов к функциям в других модулях, которые вы намеревались только использовать.
Есть два способа решения этой проблемы. Вы всегда можете квалифицировать имена функций с помощью пути модуля:
using NiceStuff struct Cat end NiceStuff.nice(::Cat) = "nice 😸"
Или вы можете import конкретное имя функции:
import NiceStuff: nice struct Cat end nice(::Cat) = "nice 😸"
Выбор зависит от стиля. Первая форма делает очевидным, что вы добавляете метод к функции в другом модуле (помните, что импорты и определение метода могут находиться в разных файлах), в то время как вторая форма короче, что особенно удобно, если вы определяете несколько методов.
После того, как переменная становится видимой через using или import, модуль не может создать собственную переменную с тем же именем. Импортированные переменные являются только для чтения; присваивание глобальной переменной всегда влияет на переменную, принадлежащую текущему модулю, или же вызывает ошибку.
Переименование с as
Идентификатор, введённый в область видимости import или using, можно переименовать с помощью ключевого слова as. Это полезно для решения конфликтов имён, а также для сокращения имён. Например, Base экспортирует имя функции read, но пакет CSV.jl также предоставляет CSV.read. Если мы собираемся многократно вызывать чтение CSV, будет удобно отказаться от квалификатора CSV.. Но тогда возникает неоднозначность, ссылаемся ли мы на Base.read или CSV.read:
julia> read; julia> import CSV: read WARNING: ignoring conflicting import of CSV.read into Main
Переименование предлагает решение:
julia> import CSV: read as rd
Сами импортированные пакеты также можно переименовать:
import BenchmarkTools as BT
as работает с using только в том случае, если в область видимости вводится один идентификатор. Например, using CSV: read as rd работает, но using CSV as C не работает, так как он действует на все экспортированные имена в CSV.
Смешивание нескольких операторов using и import
Когда используются несколько операторов using или import любой из описанных выше форм, их эффект комбинируется в том порядке, в котором они появляются. Например,
using NiceStuff # exported names and the module name import NiceStuff: nice # allows adding methods to unqualified functions
введет в область видимости все экспортированные имена NiceStuff и само имя модуля, а также позволит добавлять методы к nice без префикса имени модуля.
Управление конфликтами имён
Рассмотрим ситуацию, когда два (или более) пакета экспортируют одно и то же имя, как в
module A export f f() = 1 end module B export f f() = 2 end
Оператор using A, B работает, но когда вы пытаетесь вызвать f, вы получаете предупреждение
WARNING: both B and A export "f"; uses of it in module Main must be qualified ERROR: LoadError: UndefVarError: f not defined
Здесь Julia не может решить, к какому f вы ссылаетесь, поэтому вам нужно сделать выбор. Часто используются следующие решения:
Просто продолжайте с квалифицированными именами, такими как
A.fиB.f. Это делает контекст понятным для читателя вашего кода, особенно еслиfслучайно совпадает, но имеет разное значение в разных пакетах. Например,degreeимеет различные применения в математике, естественных науках и повседневной жизни, и эти значения следует сохранять раздельно.-
Используйте ключевое слово
asвыше, чтобы переименовать один или оба идентификатора, напримерusing A: f as f using B: f as g
сделает
B.fдоступным какg. Здесь мы предполагаем, что вы не использовалиusing Aранее, что привело быfв пространство имен. Когда рассматриваемые имена действительно разделяют значение, обычно один модуль импортирует его из другого или имеет лёгкий «базовый» пакет с единственной функцией определения интерфейса, такого как этот, который может использоваться другими пакетами. Принято, чтобы такие имена пакетов заканчивались на
...Base(что не имеет отношения к модулюBaseJulia).
Базовые определения верхнего уровня и модули без параметров
Модули автоматически содержат using Core, using Base, и определения функций eval и include, которые вычисляют выражения/файлы в глобальной области видимости этого модуля.
Если эти базовые определения не нужны, модули могут быть определены с использованием ключевого слова baremodule вместо (обратите внимание: Core всё ещё импортируется). В терминах baremodule, стандартный module выглядит следующим образом:
baremodule Mod using Base eval(x) = Core.eval(Mod, x) include(p) = Base.include(Mod, p) ... end
Стандартные модули
Существует три важных стандартных модуля:
-
Coreсодержит всю функциональность, «встроенную» в язык. -
Baseсодержит базовую функциональность, полезную практически во всех случаях. -
Mainявляется модулем верхнего уровня и текущим модулем при запуске Julia.
По умолчанию Julia поставляется с некоторыми стандартными модулями библиотеки. Они ведут себя как обычные пакеты Julia, за исключением того, что их не нужно устанавливать явно. Например, если вам нужно выполнить некоторые тесты модулей, вы можете загрузить стандартную библиотеку Test следующим образом:
using Test
Подмодули и относительные пути
Модули могут содержать подмодули, вложенные с использованием того же синтаксиса module ... end. Они могут использоваться для введения отдельных пространств имён, что может быть полезно для организации сложных кодовых баз. Обратите внимание, что каждый module вводит собственное пространство видимости, поэтому подмодули не автоматически «унаследуют» имена от своего родителя.
Рекомендуется, чтобы подмодули ссылались на другие модули в окружающем родительском модуле (включая последний) с использованием относительных квалификаторов модулей в using и import утверждениях. Относительный квалификатор модуля начинается с точки (.), что соответствует текущему модулю, и каждый последующий . приводит к родителю текущего модуля. За этим должны следовать модули при необходимости, и, наконец, само имя для доступа, всё разделено ..
Рассмотрим следующий пример, где подмодуль SubA определяет функцию, которая затем расширяется в его «братском» модуле:
module ParentModule module SubA export add_D # exported interface const D = 3 add_D(x) = x + D end using .SubA # brings `add_D` into the namespace export add_D # export it from ParentModule too module SubB import ..SubA: add_D # relative path for a “sibling” module struct Infinity end add_D(x::Infinity) = x end end
Вы можете увидеть код в пакетах, которые в аналогичной ситуации используют
import ParentModule.SubA: add_D
Однако это работает через загрузку кода и, следовательно, работает только если ParentModule находится в пакете. Лучше использовать относительные пути.
Обратите внимание, что порядок определений также имеет значение, если вы вычисляете значения. Рассмотрим
module TestPackage export x, y x = 0 module Sub using ..TestPackage z = y # ERROR: UndefVarError: y not defined end y = 1 end
где Sub пытается использовать TestPackage.y до его определения, поэтому у него нет значения.
По тем же причинам вы не можете использовать циклическую последовательность:
module A module B using ..C # ERROR: UndefVarError: C not defined end module C using ..B end end
Инициализация и предварительная компиляция модулей
Большие модули могут загружаться несколько секунд, поскольку выполнение всех операторов в модуле часто включает компиляцию большого количества кода. Julia создаёт предварительно скомпилированные кэши модуля, чтобы сократить это время.
Инкрементные предварительно скомпилированные файлы модуля создаются и используются автоматически при использовании import или using для загрузки модуля. Это заставит его автоматически скомпилироваться при первом импорте. В качестве альтернативы вы можете вручную вызвать Base.compilecache(modulename). Результирующие кэшированные файлы будут храниться в DEPOT_PATH[1]/compiled/. Впоследствии модуль автоматически перекомпилируется при using или import, если изменяются какие-либо из его зависимостей; зависимости – это модули, которые он импортирует, сборка Julia, файлы, которые он включает, или явные зависимости, объявленные include_dependency(path) в файлах модуля.
Для зависимостей файлов изменение определяется путём проверки того, не изменилось ли время изменения (mtime) каждого файла, загруженного include или явно добавленного include_dependency, или равно ли время изменения обрезке до ближайшей секунды (для учёта систем, которые не могут копировать mtime с точностью до долей секунды). Также учитывается, соответствует ли путь к файлу, выбранный механизмом поиска в require, пути, который создал файл предварительной компиляции. Также учитывается набор зависимостей, уже загруженных в текущий процесс, и не будет перекомпилировать эти модули, даже если их файлы изменяются или исчезают, чтобы избежать создания несовместимостей между выполняемой системой и кэшем предварительной компиляции.
Если вы знаете, что модуль не безопасен для предварительной компиляции (например, по одной из причин, описанных ниже), вы должны поместить __precompile__(false) в файл модуля (обычно вверху). Это заставит Base.compilecache выбросить ошибку и заставит import загрузить его напрямую в текущий процесс и пропустить предварительную компиляцию и кэширование. Это также предотвращает импорт модуля другими предварительно скомпилированными модулями.
Вам может потребоваться знать определённое поведение, присущее созданию инкрементных общих библиотек, которые могут потребовать внимательности при написании вашего модуля. Например, внешнее состояние не сохраняется. Для учёта этого явно разделяйте любые шаги инициализации, которые должны произойти во время выполнения, от шагов, которые могут произойти во время компиляции. Для этой цели Julia позволяет определить функцию __init__() в вашем модуле, которая выполняет любые шаги инициализации, которые должны произойти во время выполнения. Эта функция не будет вызвана во время компиляции (--output-*). По существу, вы можете предположить, что она будет выполнена ровно один раз за время жизни кода. Вы, конечно, можете вызвать её вручную, если необходимо, но по умолчанию предполагается, что эта функция занимается вычислением состояния для локальной машины, которое не нужно – или даже не следует – записывать в скомпилированный образ. Она будет вызвана после загрузки модуля в процесс, включая в случае загрузки в инкрементальную компиляцию (--output-incremental=yes), но не при загрузке в процесс полной компиляции.
В частности, если вы определяете function __init__() в модуле, то Julia вызовет __init__() немедленно после загрузки модуля (например, import, using, или require) во время выполнения в первый раз (то есть, __init__ вызывается только один раз и только после выполнения всех операторов в модуле). Поскольку она вызывается после полного импорта модуля, любые подмодули или другие импортированные модули вызывают свои функции __init__ до вызова функции __init__ содержащего модуля.
Два типичных применения __init__ – вызов функций инициализации времени выполнения внешних библиотек C и инициализация глобальных констант, связанных с указателями, возвращаемыми внешними библиотеками. Например, предположим, что мы вызываем библиотеку C libfoo, которая требует вызова функции инициализации foo_init() во время выполнения. Предположим, что мы также хотим определить глобальную константу foo_data_ptr, которая содержит возвращаемое значение функции void *foo_data(), определённой libfoo; эта константа должна быть инициализирована во время выполнения (а не во время компиляции), потому что адрес указателя будет изменяться от запуска к запуску. Вы можете добиться этого, определив функцию __init__ в вашем модуле:
const foo_data_ptr = Ref{Ptr{Cvoid}}(0)
function __init__()
ccall((:foo_init, :libfoo), Cvoid, ())
foo_data_ptr[] = ccall((:foo_data, :libfoo), Ptr{Cvoid}, ())
nothing
end
Заметьте, что совершенно возможно определить глобальную переменную внутри функции, как __init__; это одно из преимуществ использования динамического языка. Но сделав её константой в глобальной области видимости, мы можем гарантировать, что тип известен компилятору и позволить ему сгенерировать более оптимизированный код. Очевидно, что любые другие глобальные переменные в вашем модуле, которые зависят от foo_data_ptr, также должны быть инициализированы в __init__.
Константы, связанные с большинством объектов Julia, которые не созданы с помощью ccall, не нужно размещать в __init__: их определения можно предварительно скомпилировать и загрузить из кэшированного образа модуля. Это включает сложные объекты, выделенные в куче, такие как массивы. Однако любая функция, которая возвращает значение сырого указателя, должна вызываться во время выполнения для работы предварительной компиляции (Ptr объекты превратятся в нулевые указатели, если они не скрыты внутри объекта isbits). Это включает возвращаемые значения функций Julia @cfunction и pointer.
Типы словарей и множеств, или вообще всё, что зависит от результата работы метода hash(key), — это более сложный случай. В общем случае, когда ключи являются числами, строками, символами, диапазонами, Expr, или композициями этих типов (через массивы, кортежи, множества, пары и т. д.), их можно безопасно предварительно скомпилировать. Однако для некоторых других типов ключей, таких как Function или DataType, и для общих пользовательских типов, для которых вы не определили метод hash, метод по умолчанию hash зависит от адреса объекта в памяти (через его objectid) и поэтому может меняться от запуска к запуску. Если у вас есть один из этих типов ключей или вы не уверены, для безопасности вы можете инициализировать этот словарь внутри своей функции __init__. В качестве альтернативы вы можете использовать тип словаря IdDict, который специально обрабатывается при предварительной компиляции, так что его можно безопасно инициализировать во время компиляции.
При использовании предварительной компиляции важно чётко различать фазу компиляции и фазу выполнения. В этом режиме часто гораздо яснее видно, что Julia — это компилятор, который позволяет выполнять произвольный код Julia, а не автономный интерпретатор, который также генерирует скомпилированный код.
Другие известные потенциальные сценарии ошибок включают:
-
Глобальные счётчики (например, для попытки уникальной идентификации объектов). Рассмотрим следующий фрагмент кода:
mutable struct UniquedById myid::Int let counter = 0 UniquedById() = new(counter += 1) end endХотя цель этого кода заключалась в присвоении каждому экземпляру уникального идентификатора, значение счётчика записывается в конце компиляции. Все последующие использования этого инкрементально скомпилированного модуля будут начинаться с того же значения счётчика.
Обратите внимание, что
objectid(который работает путём хэширования указателя на память) имеет аналогичные проблемы (см. примечания к использованиюDictниже).Одним из вариантов является использование макроса для захвата
@__MODULE__и хранения его вместе с текущим значениемcounter, однако лучше переработать код, чтобы он не зависел от этого глобального состояния. Ассоциативные коллекции (такие как
DictиSet) необходимо перехешировать в__init__. (В будущем может быть предоставлен механизм для регистрации функции инициализации.)В зависимости от побочных эффектов во время компиляции, сохраняющихся во время загрузки. Примерами являются: изменение массивов или других переменных в других модулях Julia; поддержание дескрипторов открытых файлов или устройств; хранение указателей на другие системные ресурсы (включая память);
-
Создание случайных «копий» глобального состояния из другого модуля путём прямого обращения к нему вместо обращения через его путь поиска. Например, (в глобальной области):
#mystdout = Base.stdout #= will not work correctly, since this will copy Base.stdout into this module =# # instead use accessor functions: getstdout() = Base.stdout #= best option =# # or move the assignment into the runtime: __init__() = global mystdout = Base.stdout #= also works =#
Несколько дополнительных ограничений накладываются на операции, которые могут выполняться во время предварительной компиляции кода, чтобы помочь пользователю избежать других ситуаций неправильного поведения:
- Вызов
evalдля создания побочного эффекта в другом модуле. Это также вызовет предупреждение при установлении флага инкрементной предварительной компиляции. -
global constоператоры из локальной области после того, как__init__()был запущен (см. вопрос #12010 для планов добавления ошибки для этого) - Замена модуля — это ошибка времени выполнения во время инкрементной предварительной компиляции.
Несколько других моментов, о которых следует знать:
- После внесения изменений в исходные файлы (включая изменения, внесённые
Pkg.updateи т.д.) не выполняется перезагрузка/очистка кэша кода, и не выполняется очистка послеPkg.rm - Поведение совместного использования памяти переформатированного массива игнорируется предварительной компиляцией (каждое представление получает свою копию).
- Ожидание неизменности файловой системы между временем компиляции и временем выполнения, например
@__FILE__/source_path()для поиска ресурсов во время выполнения, или макрос BinDeps@checked_lib. Иногда этого избежать нельзя. Однако, когда это возможно, рекомендуется копировать ресурсы в модуль во время компиляции, чтобы они не требовались во время выполнения. -
WeakRefобъекты и деструкторы в настоящее время не обрабатываются сериализатором должным образом (это будет исправлено в ближайшем выпуске). - Обычно лучше избегать захвата ссылок на экземпляры внутренних метаданных, таких как
Method,MethodInstance,MethodTable,TypeMapLevel,TypeMapEntryи полей этих объектов, так как это может сбить сериализатор с толку и, возможно, не приведёт к желаемому результату. Это не обязательно ошибка, но вам просто нужно подготовиться к тому, что система попытается скопировать некоторые из них и создать единственный уникальный экземпляр других.
Иногда во время разработки модулей полезно отключить инкрементную предварительную компиляцию. Флаг командной строки --compiled-modules={yes|no} позволяет переключать предварительную компиляцию модулей включить и выключить. Когда Julia запускается с --compiled-modules=no, сериализованные модули в кэше компиляции игнорируются при загрузке модулей и зависимостей модулей. Base.compilecache по-прежнему можно вызвать вручную. Состояние этого флага командной строки передаётся в Pkg.build для отключения автоматического запуска предварительной компиляции при установке, обновлении и явном построении пакетов.
© 2009–2021 Jeff Bezanson, Stefan Karpinski, Viral B. Shah, and other contributors
Licensed under the MIT License.
https://docs.julialang.org/en/v1.7.0/manual/modules/