Типы
Системы типов традиционно делятся на два довольно разных лагеря: статические системы типов, где каждый выражение программы должно иметь тип, вычислимый до выполнения программы, и динамические системы типов, где о типах ничего не известно до времени выполнения, когда доступны фактические значения, обрабатываемые программой. Объектно-ориентированное программирование позволяет некоторую гибкость в статически типизированных языках, позволяя писать код без точных типов значений, известных во время компиляции. Способность писать код, который может работать с разными типами, называется полиморфизмом. Весь код в классических динамически типизированных языках полиморфный: только явным проверкой типов или при отказе объектов от поддержки операций во время выполнения ограничиваются типы каких-либо значений.
Система типов Julia динамическая, но получает некоторые преимущества статических систем типов, позволяя указывать, что определенные значения имеют конкретные типы. Это может быть весьма полезно для генерации эффективного кода, но, что еще более важно, это позволяет диспатчу методов по типам аргументов функций глубоко интегрироваться с языком. Подробное изучение диспатча методов приведено в Методах, но оно укоренено в системе типов, представленной здесь.
По умолчанию в Julia, когда типы опущены, значения могут быть любого типа. Таким образом, можно написать многие полезные функции Julia, никогда не используя явно типы. Однако, когда требуется дополнительная выразительность, можно постепенно вводить явные аннотации типов в ранее «бестиповый» код. Добавление аннотаций служит трем основным целям: использовать мощный механизм множественного диспетчера Julia, улучшить читаемость для человека и обнаруживать ошибки программиста.
Описывая Julia в терминах систем типов, она является: динамической, номинативной и параметрической. Обобщённые типы могут быть параметризованы, а иерархические отношения между типами явно декларируются, а не имплицитно определяются совместимой структурой. Одной из особенно отличительных характеристик системы типов Julia является то, что конкретные типы не могут быть подтипами друг друга: все конкретные типы являются конечными и могут иметь только абстрактные типы в качестве своих супертипов. Хотя это может на первый взгляд показаться излишне ограничительным, это имеет много положительных последствий и удивительно мало недостатков. Оказывается, что способность наследоваться поведения намного важнее, чем способность наследоваться структуры, а наследование обоих вызывает значительные трудности в традиционных объектно-ориентированных языках. Другие высокоуровневые аспекты системы типов Julia, которые следует упомянуть сразу, следующие:
- Нет разделения между объектами и не-объектными значениями: все значения в Julia являются истинными объектами, имеющими тип, относящийся к единственной, полностью связанной графе типов, все узлы которой в равной степени являются типами первого класса.
- Нет значимого понятия «типа времени компиляции»: единственный тип значения — это его фактический тип во время выполнения программы. В объектно-ориентированных языках, где сочетание статической компиляции с полиморфизмом делает это различие существенным, это называется «типом времени выполнения».
- Только значения, а не переменные, имеют типы — переменные просто имена, связанные со значениями, хотя для простоты мы можем сказать «тип переменной» как сокращение от «тип значения, к которому переменная относится».
- Как абстрактные, так и конкретные типы могут быть параметризованы другими типами. Они также могут быть параметризованы символами, значениями любого типа, для которых
isbitsвозвращает true (по существу, такие вещи, как числа и булевы значения, хранящиеся как типы C илиstructбез указателей на другие объекты), а также кортежами из них. Параметры типа могут быть опущены, когда они не должны быть ссылаться или ограничены.
Система типов Julia разработана для того, чтобы быть мощной и выразительной, но вместе с тем ясной, интуитивной и незаметной. Многие программисты Julia, возможно, никогда не почувствуют необходимости писать код, который явно использует типы. Однако некоторые виды программирования становятся яснее, проще, быстрее и надежнее с объявленными типами.
Объявления типов
Оператор :: может быть использован для добавления аннотаций типов к выражениям и переменным в программах. Есть две основные причины для этого:
- Как утверждение, чтобы помочь подтвердить, что ваша программа работает так, как вы ожидаете, и
- Чтобы предоставить дополнительную информацию о типе компилятору, что в некоторых случаях может улучшить производительность.
Когда он присоединен к выражению, вычисляющему значение, оператор :: читается как «является экземпляром». Его можно использовать в любом месте, чтобы утверждать, что значение выражения слева является экземпляром типа справа. Когда тип справа конкретный, значение слева должно иметь этот тип в качестве реализации — помните, что все конкретные типы являются конечными, так что никакая реализация не является подтипом никакой другой. Когда тип абстрактный, достаточно, чтобы значение реализовывалось конкретным типом, который является подтипом абстрактного типа. Если утверждение типа неверно, выбрасывается исключение, в противном случае возвращается значение слева:
julia> (1+2)::AbstractFloat ERROR: TypeError: in typeassert, expected AbstractFloat, got a value of type Int64 julia> (1+2)::Int 3
Это позволяет прикрепить утверждение типа к любому выражению на месте.
Когда он присоединен к переменной в левой части присваивания или как часть объявления local, оператор :: означает нечто немного другое: он объявляет переменную как имеющую всегда указанный тип, как объявление типа в статически типизированном языке, таком как C. Каждое значение, присвоенное переменной, будет преобразовано к объявленному типу с помощью convert:
julia> function foo()
x::Int8 = 100
x
end
foo (generic function with 1 method)
julia> x = foo()
100
julia> typeof(x)
Int8
Эта функция полезна для избежания проблем с производительностью, которые могут возникнуть, если одно из присваиваний переменной изменит её тип неожиданно.
Это поведение «объявления» возникает только в определённых контекстах:
local x::Int8 # in a local declaration x::Int8 = 10 # as the left-hand side of an assignment
и относится к всему текущему области видимости, даже до объявления.
Начиная с Julia 1.8, объявления типов теперь могут использоваться в глобальной области видимости, т. е. к глобальным переменным могут быть добавлены аннотации типов для обеспечения стабильности их типов.
julia> x::Int = 10
10
julia> x = 3.5
ERROR: InexactError: Int64(3.5)
julia> function foo(y)
global x = 15.8 # throws an error when foo is called
return x + y
end
foo (generic function with 1 method)
julia> foo(10)
ERROR: InexactError: Int64(15.8)
Объявления также могут быть прикреплены к определениям функций:
function sinc(x)::Float64
if x == 0
return 1
end
return sin(pi*x)/(pi*x)
end
Возврат из этой функции ведет себя так же, как присваивание переменной с объявленным типом: значение всегда преобразуется в Float64.
Абстрактные типы
Абстрактные типы не могут быть инстанцированы и служат только узлами в графе типов, тем самым описывая множества связанных конкретных типов: те конкретные типы, которые являются их потомками. Мы начинаем с абстрактных типов, даже если у них нет инстанциации, потому что они являются основой системы типов: они формируют концептуальную иерархию, которая делает систему типов Julia чем-то большим, чем просто коллекцией реализаций объектов.
Напомним, что в Целых и числах с плавающей точкой мы представили различные конкретные типы числовых значений: Int8, UInt8, Int16, UInt16, Int32, UInt32, Int64, UInt64, Int128, UInt128, Float16, Float32, и Float64. Хотя они имеют разные размеры представления, Int8, Int16, Int32, Int64 и Int128 все являются знаковыми целыми типами. Аналогично, UInt8, UInt16, UInt32, UInt64 и UInt128 — все беззнаковые целые типы, тогда как Float16, Float32 и Float64 отличаются тем, что являются типами с плавающей точкой, а не целыми. Часто бывает, что кусок кода имеет смысл только в том случае, если его аргументы являются целыми числами, но не сильно зависит от того, какого рода целыми числами. Например, алгоритм наибольшего общего делителя работает для всех целых чисел, но не будет работать для чисел с плавающей точкой. Абстрактные типы позволяют создавать иерархию типов, обеспечивая контекст, в котором могут поместиться конкретные типы. Это позволяет вам, например, легко программировать для любого типа, являющегося целым числом, без ограничения алгоритма на конкретный тип целого числа.
Абстрактные типы объявляются с помощью ключевого слова abstract type. Общие синтаксические конструкции для объявления абстрактного типа:
abstract type «name» end abstract type «name» <: «supertype» end
Ключевое слово abstract type вводит новый абстрактный тип, имя которого задается «name». Это имя может быть необязательно дополнено <: и уже существующим типом, что указывает на то, что недавно объявленный абстрактный тип является подтипом этого «родительского» типа.
Если супертип не указан, по умолчанию супертипом является Any — предопределённый абстрактный тип, экземпляром которого являются все объекты и подтипом которого являются все типы. В теории типов Any обычно называется «вершиной», потому что он находится на вершине графа типов. В Julia также есть предопределённый абстрактный «нижний» тип, который находится внизу графа типов и обозначается как Union{}. Это точная противоположность Any: ни один объект не является экземпляром Union{}, и все типы являются супертипами Union{}.
Рассмотрим некоторые абстрактные типы, составляющие числовую иерархию Julia:
abstract type Number end abstract type Real <: Number end abstract type AbstractFloat <: Real end abstract type Integer <: Real end abstract type Signed <: Integer end abstract type Unsigned <: Integer end
Тип Number является прямым дочерним типом Any, а Real — его дочерним типом. В свою очередь, Real имеет два дочерних типа (их больше, но здесь показаны только два; остальные рассмотрим позже): Integer и AbstractFloat, разделяя мир на представления целых чисел и представлений действительных чисел. Представления действительных чисел включают типы с плавающей точкой, но также включают и другие типы, такие как рациональные числа. AbstractFloat включает только представления действительных чисел с плавающей точкой. Целые числа далее подразделяются на разновидности Signed и Unsigned.
Оператор <: в общем случае означает «является подтипом», и, используемый в объявлениях, как в приведённых выше, объявляет тип справа непосредственным супертипом вновь объявленного типа. Он также может использоваться в выражениях как оператор подтипов, который возвращает true, когда его левый операнд является подтипом его правого операнда:
julia> Integer <: Number true julia> Integer <: AbstractFloat false
Важное применение абстрактных типов — предоставление реализаций по умолчанию для конкретных типов. Рассмотрим, например:
function myplus(x,y)
x+y
end
Первое, что следует отметить, — это то, что вышеуказанные объявления аргументов эквивалентны x::Any и y::Any. Когда эта функция вызывается, скажем, как myplus(2,5), диспетчер выбирает наиболее специфичный метод с именем myplus , который соответствует заданным аргументам. (См. Методы для получения дополнительной информации о множественной диспетчеризации.)
Предположим, что метод, более специфичный, чем вышеуказанный, не найден. Затем Julia внутренне определяет и компилирует метод под названием myplus специально для двух Int аргументов, основанный на обобщённой функции, приведённой выше, т. е., он неявно определяет и компилирует:
function myplus(x::Int,y::Int)
x+y
end
и, наконец, вызывает этот конкретный метод.
Таким образом, абстрактные типы позволяют программистам писать обобщённые функции, которые впоследствии могут использоваться в качестве метода по умолчанию многими комбинациями конкретных типов. Благодаря множественной диспетчеризации программист имеет полный контроль над тем, используется ли метод по умолчанию или более специфичный метод.
Важно отметить, что при использовании функции, аргументы которой являются абстрактными типами, нет потери производительности, потому что она перекомпилируется для каждой пары конкретных типов аргументов, с которыми она вызывается. (Однако возможны проблемы с производительностью в случае аргументов функций, которые являются контейнерами абстрактных типов; см. Рекомендации по производительности.)
Примитивные типы
Практически всегда предпочтительнее оборачивать существующий примитивный тип в новый составной тип, чем определять свой собственный примитивный тип.
Эта функциональность позволяет Julia запустить стандартные примитивные типы, которые поддерживает LLVM. После их определения, вряд ли есть причина определять больше.
Примитивный тип — это конкретный тип, данные которого состоят из простых битов. Классическими примерами примитивных типов являются целые числа и значения с плавающей точкой. В отличие от большинства языков, Julia позволяет объявлять свои собственные примитивные типы, а не предоставлять только фиксированный набор встроенных.
Фактически, стандартные примитивные типы определяются в самом языке:
primitive type Float16 <: AbstractFloat 16 end primitive type Float32 <: AbstractFloat 32 end primitive type Float64 <: AbstractFloat 64 end primitive type Bool <: Integer 8 end primitive type Char <: AbstractChar 32 end primitive type Int8 <: Signed 8 end primitive type UInt8 <: Unsigned 8 end primitive type Int16 <: Signed 16 end primitive type UInt16 <: Unsigned 16 end primitive type Int32 <: Signed 32 end primitive type UInt32 <: Unsigned 32 end primitive type Int64 <: Signed 64 end primitive type UInt64 <: Unsigned 64 end primitive type Int128 <: Signed 128 end primitive type UInt128 <: Unsigned 128 end
Общие синтаксисы для объявления примитивного типа:
primitive type «name» «bits» end primitive type «name» <: «supertype» «bits» end
Количество битов указывает, сколько памяти требуется для типа, а имя даёт новому типу имя. Примитивный тип может быть необязательно объявлен как подтип некоторого супертипа. Если супертип опущен, то по умолчанию тип имеет Any в качестве непосредственного супертипа. Таким образом, объявление Bool означает, что для хранения логического значения требуется восемь битов, и Integer является его непосредственным супертипом. В настоящее время поддерживаются только размеры, кратные 8 битам, и вы, вероятно, столкнётесь с ошибками LLVM при использовании размеров, отличных от указанных выше. Поэтому логические значения, хотя им на самом деле требуется всего один бит, не могут быть объявлены меньше восьми битов.
Типы Bool, Int8 и UInt8 имеют одинаковое представление: они представляют собой восьмибитные блоки памяти. Однако, поскольку система типов Julia номинативна, они не взаимозаменяемы, несмотря на идентичную структуру. Фундаментальное различие между ними заключается в том, что у них разные супертипы: непосредственный супертип Bool — Integer, супертип Int8 — Signed, а супертип UInt8 — Unsigned. Все остальные различия между Bool, Int8 и UInt8 связаны с поведением — способом определения функций для работы с объектами этих типов в качестве аргументов. Именно поэтому необходима номинативная система типов: если структура определяет тип, который, в свою очередь, диктует поведение, то невозможно заставить Bool вести себя по-другому, чем Int8 или UInt8.
Составные типы
Составные типы называются записями, структурами или объектами в различных языках. Составной тип — это набор именованных полей, экземпляр которого может рассматриваться как одно значение.
Во многих языках составные типы являются единственным видом определяемых пользователем типов, и они являются, пожалуй, наиболее часто используемыми определяемыми пользователем типами и в Julia.
В распространённых языках объектно-ориентированного программирования, таких как C++, Java, Python и Ruby, составные типы также имеют связанные с ними именованные функции, а комбинация называется «объектом». В более чистых объектно-ориентированных языках, таких как Ruby или Smalltalk, все значения являются объектами, независимо от того, являются ли они составными или нет. В менее чистых объектно-ориентированных языках, включая C++ и Java, некоторые значения, такие как целые числа и значения с плавающей точкой, не являются объектами, в то время как экземпляры определяемых пользователем составных типов являются истинными объектами с связанными методами.
В Julia все значения являются объектами, но функции не связаны с объектами, над которыми они работают. Это необходимо, поскольку Julia выбирает, какой метод функции использовать, путём множественной диспетчеризации, что означает, что при выборе метода учитываются типы *всех* аргументов функции, а не только первого (см. Методы для получения дополнительной информации о методах и диспетчеризации). Таким образом, неподходяще, чтобы функции «принадлежали» только первому аргументу.
Организация методов в функции объектов, а не именованные наборы методов «внутри» каждого объекта, оказывается очень полезным аспектом проектирования языка.
Составные типы вводятся с помощью ключевого слова struct, за которым следует блок имён полей, необязательно аннотированных типами с помощью оператора :::
julia> struct Foo
bar
baz::Int
qux::Float64
end
Поля без аннотации типа по умолчанию имеют тип Any, и соответственно могут содержать любые типы значений.
Новые объекты типа Foo создаются путём применения объекта типа Foo как функции к значениям его полей:
julia> foo = Foo("Hello, world.", 23, 1.5)
Foo("Hello, world.", 23, 1.5)
julia> typeof(foo)
Foo
Когда тип применяется как функция, он называется конструктором. Два конструктора генерируются автоматически (они называются конструкторами по умолчанию). Один принимает любые аргументы и вызывает convert для преобразования их в типы полей, а другой принимает аргументы, которые точно соответствуют типам полей. Причина генерации обоих заключается в том, что это упрощает добавление новых определений, не заменяя непреднамеренно конструктор по умолчанию.
Поскольку поле bar не ограничено по типу, подойдет любое значение. Однако значение для baz должно быть преобразуемо в Int:
julia> Foo((), 23.5, 1) ERROR: InexactError: Int64(23.5) Stacktrace: [...]
Список имён полей можно найти с помощью функции fieldnames.
julia> fieldnames(Foo) (:bar, :baz, :qux)
Значения полей составного объекта можно получить, используя традиционную нотацию foo.bar:
julia> foo.bar "Hello, world." julia> foo.baz 23 julia> foo.qux 1.5
Составные объекты, объявленные с помощью ключевого слова struct , являются неизменяемыми; их нельзя изменить после создания.
Это может показаться странным на первый взгляд, но у этого есть несколько преимуществ:
- Это может быть более эффективным. Некоторые структуры могут быть эффективно упакованы в массивы, и в некоторых случаях компилятор может полностью избежать выделения неизменяемых объектов.
- Невозможно нарушить инварианты, предоставляемые конструкторами типа.
- Код, использующий неизменяемые объекты, может быть легче для понимания.
Неизменяемый объект может содержать изменяемые объекты, такие как массивы, в качестве полей. Эти содержащиеся объекты останутся изменяемыми; только поля самого неизменяемого объекта нельзя изменить на указание на другие объекты.
В необходимых случаях изменяемые составные объекты могут быть объявлены с помощью ключевого слова mutable struct, которое будет рассмотрено в следующей секции.
Если все поля неизменяемой структуры неотличимы (===) то два неизменяемых значения, содержащие эти поля, также будут неотличимы:
julia> struct X
a::Int
b::Float64
end
julia> X(1, 2) === X(1, 2)
true
Есть много других вещей, которые нужно сказать о том, как создаются экземпляры составных типов, но для этого обсуждения необходимо обратиться к Параметрическим типам и Методам, и это достаточно важно, чтобы рассмотреть этот вопрос в отдельном разделе: Конструкторы.
Для многих определяемых пользователем типов X, вы можете определить метод Base.broadcastable(x::X) = Ref(x), чтобы экземпляры этого типа действовали как 0-мерные «скаляры» для векторного умножения.
Изменяемые составные типы
Если составной тип объявлен с mutable struct вместо struct, то его экземпляры могут быть изменены:
julia> mutable struct Bar
baz
qux::Float64
end
julia> bar = Bar("Hello", 1.5);
julia> bar.qux = 2.0
2.0
julia> bar.baz = 1//2
1//2
Дополнительный интерфейс между полями и пользователем может быть предоставлен через Свойства экземпляров. Это предоставляет больший контроль над тем, что можно получить и изменить, используя обозначение bar.baz.
Для поддержки мутации такие объекты обычно выделяются в куче и имеют стабильные адреса памяти. Изменяемый объект похож на небольшой контейнер, который может содержать разные значения со временем, и поэтому его можно надежно идентифицировать только по его адресу. В противоположность этому, экземпляр неизменяемого типа связан со специфическими значениями полей — значения полей полностью описывают объект. При решении вопроса о том, следует ли сделать тип изменяемым, задайте вопрос, будут ли два экземпляра с одинаковыми значениями полей считаться идентичными, или им нужно изменять независимо со временем. Если они будут считаться идентичными, тип, вероятно, должен быть неизменяемым.
Для повторения, две основные свойства определяют неизменяемость в Julia:
- Не разрешается изменять значение неизменяемого типа.
- Для типов битов это означает, что битовая структура значения, однажды заданная, никогда не изменится, и это значение является идентификатором типа битов.
- Для составных типов это означает, что идентификаторы значений его полей никогда не изменятся. Когда поля являются типами битов, это означает, что их биты никогда не изменятся; для полей, значениями которых являются изменяемые типы, такие как массивы, это означает, что поля всегда будут ссылаться на то же изменяемое значение, даже если содержание этого изменяемого значения само может быть изменено.
- Объект с неизменяемым типом может быть свободно скопирован компилятором, так как его неизменяемость делает невозможным программно отличить исходный объект от копии.
- В частности, это означает, что достаточно малые неизменяемые значения, такие как целые числа и числа с плавающей точкой, обычно передаются функциям в регистрах (или выделяются в стеке).
- Изменяемые значения, с другой стороны, выделяются в куче и передаются функциям в качестве указателей на значения, выделенные в куче, за исключением случаев, когда компилятор уверен, что нет способа сказать, что этого не происходит.
В случаях, когда одно или несколько полей структуры, которая в остальном является изменяемой, известно как неизменяемое, эти поля можно объявить как таковые, используя const, как показано ниже. Это позволяет использовать некоторые, но не все оптимизации неизменяемых структур и может использоваться для обеспечения инвариантов для конкретных полей, помеченных как const.
const аннотирование полей изменяемых структур требует по крайней мере Julia 1.8.
julia> mutable struct Baz
a::Int
const b::Float64
end
julia> baz = Baz(1, 1.5);
julia> baz.a = 2
2
julia> baz.b = 2.0
ERROR: setfield!: const field .b of type Baz cannot be changed
[...]
Объявленные типы
Три типа (абстрактный, примитивный, составной), обсуждавшиеся в предыдущих разделах, на самом деле тесно связаны. У них есть общие ключевые свойства:
- Они объявляются явно.
- Они имеют имена.
- У них есть явно объявленные надтипы.
- У них могут быть параметры.
Благодаря этим общим свойствам, эти типы внутренне представлены как экземпляры одного и того же понятия, DataType, которое является типом любого из этих типов:
julia> typeof(Real) DataType julia> typeof(Int) DataType
DataType может быть абстрактным или конкретным. Если он конкретный, то он имеет заданный размер, структуру хранения и (необязательно) имена полей. Таким образом, примитивный тип — это DataType с ненулевым размером, но без имён полей. Составной тип — это DataType с именами полей или пустой (нулевой размер).
Каждое конкретное значение в системе является экземпляром некоторого DataType.
Объединения типов
Объединение типов — это специальный абстрактный тип, который включает в себя в качестве объектов все экземпляры любого из типов его аргументов, созданных с использованием специального ключевого слова Union:
julia> IntOrString = Union{Int,AbstractString}
Union{Int64, AbstractString}
julia> 1 :: IntOrString
1
julia> "Hello!" :: IntOrString
"Hello!"
julia> 1.0 :: IntOrString
ERROR: TypeError: in typeassert, expected Union{Int64, AbstractString}, got a value of type Float64
Компиляторы многих языков имеют внутреннюю конструкцию объединения для работы с типами; Julia просто предоставляет её программисту. Julia компилятор способен генерировать эффективный код при наличии Union типов с небольшим количеством типов [1], генерируя специализированный код в отдельных ветвях для каждого возможного типа.
Особо полезным случаем Union типа является Union{T, Nothing}, где T может быть любым типом, и Nothing — это тип-объект, единственный экземпляр которого — объект nothing. Этот шаблон является аналогом Julia типов Nullable, Option или Maybe в других языках. Объявление аргумента функции или поля как Union{T, Nothing} позволяет установить его либо в значение типа T, либо в nothing для указания отсутствия значения. См. эту запись FAQ для получения дополнительной информации.
Параметрические типы
Важной и мощной особенностью системы типов Julia является параметризация: типы могут принимать параметры, так что объявления типов фактически вводят целое семейство новых типов — по одному для каждой возможной комбинации значений параметров. Существует множество языков, которые поддерживают ту или иную форму параметрической типизации, в которой структуры данных и алгоритмы для их обработки могут быть определены без указания точных типов. Например, некоторая форма параметрической типизации существует в ML, Haskell, Ada, Eiffel, C++, Java, C#, F# и Scala, чтобы назвать лишь некоторые. Некоторые из этих языков поддерживают истинную параметрическую полиморфию (например, ML, Haskell, Scala), а другие — неявную, основанную на шаблонах, параметрическую типизацию (например, C++, Java). Учитывая столь большое разнообразие видов параметрической типизации в различных языках, мы даже не будем пытаться сравнивать параметрические типы Julia с другими языками, а вместо этого сосредоточимся на объяснении системы Julia сама по себе. Тем не менее, мы отметим, что поскольку Julia — динамически типизированный язык и не нужно принимать все решения о типах на этапе компиляции, многие традиционные трудности, встречающиеся в статических параметризованных системах типов, можно относительно легко решить.
Все объявленные типы (разновидность DataType ) могут быть параметризованными, с одинаковым синтаксисом в каждом случае. Мы обсудим их в следующем порядке: сначала параметризованные составные типы, затем параметризованные абстрактные типы и, наконец, параметризованные примитивные типы.
Параметризованные составные типы
Параметры типа вводятся сразу после имени типа, заключённые в фигурные скобки:
julia> struct Point{T}
x::T
y::T
end
Это объявление определяет новый параметризованный тип Point{T}, содержащий две «координаты» типа T. Что, спросите вы, такое T? Это именно смысл параметрических типов: это может быть любой тип (или значение любого типа битов, на самом деле, хотя здесь он явно используется как тип). Point{Float64} — это конкретный тип, эквивалентный типу, полученному заменой T в определении Point на Float64. Таким образом, это единственное объявление фактически объявляет неограниченное число типов: Point{Float64}, Point{AbstractString}, Point{Int64}, и т. д. Каждый из них теперь является применимым конкретным типом:
julia> Point{Float64}
Point{Float64}
julia> Point{AbstractString}
Point{AbstractString}
Тип Point{Float64} — это точка, координаты которой являются значениями с плавающей точкой двойной точности, а тип Point{AbstractString} — это «точка», «координаты» которой — строковые объекты (см. Строки).
Point сам по себе также является допустимым объектом типа, содержащим все экземпляры Point{Float64}, Point{AbstractString}, и т. д. как подтипы:
julia> Point{Float64} <: Point
true
julia> Point{AbstractString} <: Point
true
Другие типы, конечно, не являются подтипами его:
julia> Float64 <: Point false julia> AbstractString <: Point false
Конкретные Point типы с разными значениями T никогда не являются подтипами друг друга:
julia> Point{Float64} <: Point{Int64}
false
julia> Point{Float64} <: Point{Real}
false
Этот последний момент очень важен: даже если Float64 <: Real у нас НЕ БУДЕТ Point{Float64} <: Point{Real}.
Другими словами, в терминологии теории типов параметры типов Julia являются инвариантными, а не ковариантными (или даже контравариантными). Это по практическим причинам: хотя любой экземпляр Point{Float64} концептуально может быть похож на экземпляр Point{Real} тоже, два типа имеют разные представления в памяти:
- Экземпляр
Point{Float64}может быть компактным и эффективным представлен как пара значений двойной точности; - Экземпляр
Point{Real}должен уметь содержать любую пару экземпляровReal. Поскольку объекты, являющиеся экземплярамиReal, могут быть произвольного размера и структуры, на практике экземплярPoint{Real}должен представляться парой указателей на индивидуально выделенныеRealобъекты.
Эффективность хранения Point{Float64} объектов с непосредственными значениями значительно увеличивается в случае массивов: массив Array{Float64} может храниться как непрерывный блок памяти из значений с плавающей точкой двойной точности, тогда как Array{Real} должен быть массивом указателей на индивидуально выделенные Real объекты — которые могут быть «упакованными» значениями с плавающей точкой двойной точности, но также могут быть произвольно большими и сложными объектами, которые объявлены реализациями абстрактного типа Real.
Поскольку Point{Float64} не является подтипом Point{Real}, следующий метод не может быть применён к аргументам типа Point{Float64}:
function norm(p::Point{Real})
sqrt(p.x^2 + p.y^2)
end
Правильный способ определения метода, принимающего все аргументы типа Point{T} , где T является подтипом Real, заключается в следующем:
function norm(p::Point{<:Real})
sqrt(p.x^2 + p.y^2)
end
(Эквивалентно, можно определить function norm(p::Point{T} where T<:Real) или function norm(p::Point{T}) where T<:Real; см. Типы UnionAll.)
Дополнительные примеры будут рассмотрены позже в Методах.
Как создать объект Point? Можно определить пользовательские конструкторы для составных типов, которые будут подробно рассмотрены в Конструкторах, но в отсутствие специальных объявлений конструкторов есть два стандартных способа создания новых составных объектов: один, в котором параметры типа явно задаются, и другой, в котором они подразумеваются аргументами конструктора объекта.
Поскольку тип Point{Float64} является конкретным типом, эквивалентным Point с Float64 вместо T, его можно использовать в качестве соответствующего конструктора:
julia> p = Point{Float64}(1.0, 2.0)
Point{Float64}(1.0, 2.0)
julia> typeof(p)
Point{Float64}
Для конструктора по умолчанию для каждого поля необходимо предоставить ровно один аргумент:
julia> Point{Float64}(1.0)
ERROR: MethodError: no method matching Point{Float64}(::Float64)
[...]
julia> Point{Float64}(1.0,2.0,3.0)
ERROR: MethodError: no method matching Point{Float64}(::Float64, ::Float64, ::Float64)
[...]
Для параметрических типов генерируется только один конструктор по умолчанию, так как его переопределение невозможно. Этот конструктор принимает любые аргументы и преобразует их в типы полей.
Во многих случаях излишне указывать тип объекта Point , который нужно создать, поскольку типы аргументов вызова конструктора уже неявно предоставляют информацию о типе. По этой причине вы также можете использовать Point как конструктор, при условии, что подразумеваемое значение типа параметра T однозначно:
julia> p1 = Point(1.0,2.0)
Point{Float64}(1.0, 2.0)
julia> typeof(p1)
Point{Float64}
julia> p2 = Point(1,2)
Point{Int64}(1, 2)
julia> typeof(p2)
Point{Int64}
В случае Point, тип T однозначно подразумевается только в том случае, если два аргумента Point имеют одинаковый тип. Если это не так, конструктор завершится с ошибкой MethodError:
julia> Point(1,2.5) ERROR: MethodError: no method matching Point(::Int64, ::Float64) Closest candidates are: Point(::T, !Matched::T) where T @ Main none:2 Stacktrace: [...]
Можно определить методы конструкторов для правильной обработки таких смешанных случаев, но это не будет обсуждаться до Конструкторов.
Параметрические абстрактные типы
Объявления параметрических абстрактных типов объявляют набор абстрактных типов аналогичным образом:
julia> abstract type Pointy{T} end
С этим объявлением Pointy{T} является отдельным абстрактным типом для каждого типа или целого значения T. Как и в случае параметрических составных типов, каждый такой экземпляр является подтипом Pointy:
julia> Pointy{Int64} <: Pointy
true
julia> Pointy{1} <: Pointy
true
Параметрические абстрактные типы инвариантны, как и параметрические составные типы:
julia> Pointy{Float64} <: Pointy{Real}
false
julia> Pointy{Real} <: Pointy{Float64}
false
Запись Pointy{<:Real} может использоваться для выражения аналога Julia ковариантного типа, а Pointy{>:Int} – аналога контравариантного типа, но технически они представляют множества типов (см. Типы UnionAll).
julia> Pointy{Float64} <: Pointy{<:Real}
true
julia> Pointy{Real} <: Pointy{>:Int}
true
Так же, как обычные абстрактные типы служат для создания полезной иерархии типов над конкретными типами, параметрические абстрактные типы выполняют ту же функцию по отношению к параметрическим составным типам. Например, мы могли бы объявить Point{T} подтипом Pointy{T} следующим образом:
julia> struct Point{T} <: Pointy{T}
x::T
y::T
end
Ввиду такого объявления для каждого выбора T Point{T} является подтипом Pointy{T}:
julia> Point{Float64} <: Pointy{Float64}
true
julia> Point{Real} <: Pointy{Real}
true
julia> Point{AbstractString} <: Pointy{AbstractString}
true
Это отношение также инвариантно:
julia> Point{Float64} <: Pointy{Real}
false
julia> Point{Float64} <: Pointy{<:Real}
true
Какую цель служат параметрические абстрактные типы, такие как Pointy? Представьте, что мы создаем реализацию точки, которая требует только одной координаты, потому что точка находится на диагональной прямой x = y:
julia> struct DiagPoint{T} <: Pointy{T}
x::T
end
Теперь и Point{Float64}, и DiagPoint{Float64} являются реализациями абстракции Pointy{Float64}, и аналогично для любого другого возможного выбора типа T. Это позволяет программировать на общем интерфейсе, который используется всеми объектами Pointy, реализованном для Point и DiagPoint. Однако это нельзя полностью продемонстрировать, пока мы не представим методы и диспетчеризацию в следующем разделе Методы.
Существуют ситуации, когда не имеет смысла, чтобы параметры типа свободно изменялись по всем возможным типам. В таких ситуациях можно ограничить диапазон T следующим образом:
julia> abstract type Pointy{T<:Real} end
С таким объявлением допустимо использовать любой тип, который является подтипом Real вместо T, но не типы, которые не являются подтипами Real:
julia> Pointy{Float64}
Pointy{Float64}
julia> Pointy{Real}
Pointy{Real}
julia> Pointy{AbstractString}
ERROR: TypeError: in Pointy, in T, expected T<:Real, got Type{AbstractString}
julia> Pointy{1}
ERROR: TypeError: in Pointy, in T, expected T<:Real, got a value of type Int64
Параметры типа параметрических составных типов можно ограничить аналогичным образом:
struct Point{T<:Real} <: Pointy{T}
x::T
y::T
end
Чтобы привести реальный пример того, как вся эта параметрическая механика типов может быть полезна, вот фактическое определение неизменяемого типа Julia Rational (кроме конструктора, который мы опускаем для простоты), представляющего точное отношение целых чисел:
struct Rational{T<:Integer} <: Real
num::T
den::T
end
Имеет смысл брать отношения только целых значений, поэтому параметр типа T ограничен подтипом Integer, и отношение целых чисел представляет собой значение на числовой прямой, поэтому любой Rational является экземпляром абстракции Real.
Кортежи
Кортежи являются абстракцией аргументов функции – без самой функции. Важными аспектами аргументов функции являются их порядок и их типы. Поэтому тип кортежа похож на параметрический неизменяемый тип, где каждый параметр является типом одного поля. Например, тип кортежа из 2 элементов похож на следующий неизменяемый тип:
struct Tuple2{A,B}
a::A
b::B
end
Однако есть три ключевых отличия:
- Типы кортежей могут иметь любое количество параметров.
- Типы кортежей ковариантны по своим параметрам:
Tuple{Int}является подтипомTuple{Any}. ПоэтомуTuple{Any}считается абстрактным типом, а типы кортежей являются конкретными только если их параметры являются таковыми. - У кортежей нет имен полей; к полям можно получить доступ только по индексу.
Значения кортежей записываются в скобках с запятыми. При создании кортежа соответствующий тип кортежа генерируется по требованию:
julia> typeof((1,"foo",2.5))
Tuple{Int64, String, Float64}
Обратите внимание на последствия ковариантности:
julia> Tuple{Int,AbstractString} <: Tuple{Real,Any}
true
julia> Tuple{Int,AbstractString} <: Tuple{Real,Real}
false
julia> Tuple{Int,AbstractString} <: Tuple{Real,}
false
Интуитивно это соответствует тому, что тип аргументов функции является подтипом сигнатуры функции (когда сигнатура совпадает).
Типы кортежей с переменным числом аргументов
Последний параметр типа кортежа может быть специальным значением Vararg, которое обозначает любое количество последующих элементов:
julia> mytupletype = Tuple{AbstractString,Vararg{Int}}
Tuple{AbstractString, Vararg{Int64}}
julia> isa(("1",), mytupletype)
true
julia> isa(("1",1), mytupletype)
true
julia> isa(("1",1,2), mytupletype)
true
julia> isa(("1",1,2,3.0), mytupletype)
false
Более того, Vararg{T} соответствует нулю или более элементам типа T. Типы кортежей с переменным числом аргументов используются для представления аргументов, принимаемых методами с переменным числом аргументов (см. Функции с переменным числом аргументов).
Специальное значение Vararg{T,N} (используемое в качестве последнего параметра типа кортежа) соответствует ровно N элементам типа T. NTuple{N,T} – удобный псевдоним для Tuple{Vararg{T,N}}, т.е. типа кортежа, содержащего ровно N элементов типа T.
Именованные типы кортежей
Именованные кортежи являются экземплярами типа NamedTuple, который имеет два параметра: кортеж символов, задающих имена полей, и тип кортежа, задающий типы полей. Для удобства типы NamedTuple печатаются с помощью макроса @NamedTuple, который предоставляет удобный синтаксис в стиле struct для объявления этих типов через объявления key::Type, где пропущенное ::Type соответствует ::Any.
julia> typeof((a=1,b="hello")) # prints in macro form
@NamedTuple{a::Int64, b::String}
julia> NamedTuple{(:a, :b), Tuple{Int64, String}} # long form of the type
@NamedTuple{a::Int64, b::String}
Форма макроса begin ... end @NamedTuple позволяет разбить объявления на несколько строк (аналогично объявлению структуры), но в остальном эквивалентна:
julia> @NamedTuple begin
a::Int
b::String
end
@NamedTuple{a::Int64, b::String}
Тип NamedTuple может использоваться как конструктор, принимающий один аргумент – кортеж. Созданный тип NamedTuple может быть либо конкретным типом, с указанием обоих параметров, либо типом, который указывает только имена полей:
julia> @NamedTuple{a::Float32,b::String}((1, ""))
(a = 1.0f0, b = "")
julia> NamedTuple{(:a, :b)}((1, ""))
(a = 1, b = "")
Если типы полей указаны, аргументы преобразуются. В противном случае используются типы аргументов.
Параметрические примитивные типы
Примитивные типы также могут быть объявлены параметрически. Например, указатели представлены как примитивные типы, которые объявлялись бы в Julia так:
# 32-bit system:
primitive type Ptr{T} 32 end
# 64-bit system:
primitive type Ptr{T} 64 end
Несколько необычной особенностью этих объявлений по сравнению с типичными параметрическими составными типами является то, что параметр типа T не используется в определении самого типа – он просто абстрактная метка, по существу определяющая целое семейство типов с одинаковой структурой, отличающиеся только параметром типа. Таким образом, Ptr{Float64} и Ptr{Int64} – это разные типы, даже если они имеют одинаковое представление. И конечно, все конкретные типы указателей являются подтипами обширного типа Ptr:
julia> Ptr{Float64} <: Ptr
true
julia> Ptr{Int64} <: Ptr
true
Типы UnionAll
Мы говорили, что параметрический тип, такой как Ptr , действует как супертип всех его экземпляров (Ptr{Int64} и т.д.). Как это работает? Ptr сам по себе не может быть обычным типом данных, так как без знания типа ссылаемых данных тип явно не может быть использован для операций с памятью. Ответ заключается в том, что Ptr (или другие параметрические типы, такие как Array ) – это другой тип, называемый типом UnionAll. Такой тип выражает итерационное объединение типов для всех значений некоторого параметра.
UnionAll типы обычно записываются с помощью ключевого слова where. Например, Ptr можно было бы более точно записать как Ptr{T} where T, что означает все значения, тип которых Ptr{T} для некоторого значения T. В этом контексте параметр T также часто называют «переменной типа», поскольку он похож на переменную, которая принимает значения типов. Каждая where вводит одну переменную типа, поэтому эти выражения вложены для типов с несколькими параметрами, например, Array{T,N} where N where T.
Синтаксис применения типа A{B,C} требует, чтобы A был типом UnionAll, и сначала подставляет B для внешней переменной типа в A. Результат должен быть другим типом UnionAll, в который затем подставляется C . Таким образом, A{B,C} эквивалентно A{B}{C}. Это объясняет, почему можно частично инициализировать тип, как в Array{Float64}: первое значение параметра было закреплено, но второе всё ещё принимает все возможные значения. Используя явный синтаксис where, можно закрепить любой подмножество параметров. Например, тип всех одномерных массивов можно записать как Array{T,1} where T.
Переменные типа могут быть ограничены отношениями подтипов. Array{T} where T<:Integer относится ко всем массивам, тип элементов которых является каким-либо Integer. Синтаксис Array{<:Integer} является удобным сокращением для Array{T} where T<:Integer. Переменные типа могут иметь как нижние, так и верхние границы. Array{T} where Int<:T<:Number относится ко всем массивам из Number, которые могут содержать Int (поскольку T должен быть не меньше Int). Синтаксис where T>:Int также работает для указания только нижней границы переменной типа, и Array{>:Int} эквивалентно Array{T} where T>:Int.
Поскольку выражения с where вложены, границы переменных типа могут ссылаться на внешние переменные типа. Например, Tuple{T,Array{S}} where S<:AbstractArray{T} where T<:Real относится к 2-кортежам, первый элемент которых является каким-то Real, а второй – это Array любого вида массива, тип элементов которого содержит тип первого элемента кортежа.
Ключевое слово where само по себе может быть вложено в более сложное объявление. Например, рассмотрим два типа, созданные следующими объявлениями:
julia> const T1 = Array{Array{T, 1} where T, 1}
Vector{Vector} (alias for Array{Array{T, 1} where T, 1})
julia> const T2 = Array{Array{T, 1}, 1} where T
Array{Vector{T}, 1} where T
Тип T1 определяет одномерный массив одномерных массивов; каждый из внутренних массивов состоит из объектов одного типа, но этот тип может меняться от одного внутреннего массива к другому. С другой стороны, тип T2 определяет одномерный массив одномерных массивов, все внутренние массивы которых должны иметь один и тот же тип. Обратите внимание, что T2 – это абстрактный тип, например, Array{Array{Int,1},1} <: T2, тогда как T1 – это конкретный тип. Вследствие этого, T1 можно создать с конструктором без аргументов a=T1(), но T2 нельзя.
Существует удобный синтаксис для именования таких типов, похожий на сокращённую форму синтаксиса определения функций:
Vector{T} = Array{T, 1}
Это эквивалентно const Vector = Array{T,1} where T. Запись Vector{Float64} эквивалентна записи Array{Float64,1}, и тип-оболочка Vector имеет в качестве экземпляров все Array объекты, где второй параметр – число измерений массива – равен 1, независимо от того, какой тип элемента. В языках, где параметрические типы всегда должны быть указаны полностью, это не очень полезно, но в Julia это позволяет записать просто Vector для абстрактного типа, включающего все одномерные плотные массивы любого типа элемента.
Типы-синглтоны
Неизменяемые составные типы без полей называются синглтонами. Формально, если
-
T– это неизменяемый составной тип (т.е. определён сstruct), -
a isa T && b isa Tподразумеваетa === b,
то T – это тип-синглтон.[2] Base.issingletontype можно использовать для проверки, является ли тип типом-синглтоном. Абстрактные типы не могут быть типами-синглтонами по определению.
Из определения следует, что может быть только один экземпляр таких типов:
julia> struct NoFields
end
julia> NoFields() === NoFields()
true
julia> Base.issingletontype(NoFields)
true
Функция === подтверждает, что сконструированные экземпляры NoFields на самом деле являются одним и тем же.
Параметрические типы могут быть типами-синглтонами, когда выполняется вышеприведенное условие. Например,
julia> struct NoFieldsParam{T}
end
julia> Base.issingletontype(NoFieldsParam) # Can't be a singleton type ...
false
julia> NoFieldsParam{Int}() isa NoFieldsParam # ... because it has ...
true
julia> NoFieldsParam{Bool}() isa NoFieldsParam # ... multiple instances.
true
julia> Base.issingletontype(NoFieldsParam{Int}) # Parametrized, it is a singleton.
true
julia> NoFieldsParam{Int}() === NoFieldsParam{Int}()
true
Типы функций
Каждая функция имеет свой тип, который является подтипом Function.
julia> foo41(x) = x + 1 foo41 (generic function with 1 method) julia> typeof(foo41) typeof(foo41) (singleton type of function foo41, subtype of Function)
Обратите внимание, как typeof(foo41) печатается как само себя. Это всего лишь соглашение для вывода, поскольку это объект первого класса, который может использоваться как любое другое значение:
julia> T = typeof(foo41) typeof(foo41) (singleton type of function foo41, subtype of Function) julia> T <: Function true
Типы функций, определённых на верхнем уровне, являются типами-синглтонами. При необходимости можно сравнить их с ===.
Замыкания также имеют свой тип, который обычно выводится с именами, оканчивающимися на #<number>. Имена и типы функций, определённых в разных местах, различаются, но не гарантируется, что они будут выводиться одинаково в разных сессиях.
julia> typeof(x -> x + 1) var"#9#10"
Типы замыканий не обязательно являются типами-синглтонами.
julia> addy(y) = x -> x + y addy (generic function with 1 method) julia> typeof(addy(1)) === typeof(addy(2)) true julia> addy(1) === addy(2) false julia> Base.issingletontype(typeof(addy(1))) false
Type{T} селекторы типов
Для каждого типа T, Type{T} – это абстрактный параметрический тип, единственным экземпляром которого является объект T. До тех пор, пока мы не обсудим Параметрические методы и преобразования, трудно объяснить полезность этой конструкции, но коротко: она позволяет специализировать поведение функций по конкретным типам в качестве значений. Это полезно для написания методов (особенно параметрических), поведение которых зависит от типа, который задаётся в качестве явного аргумента, а не подразумевается типом одного из его аргументов.
Поскольку определение немного сложно для анализа, давайте рассмотрим некоторые примеры:
julia> isa(Float64, Type{Float64})
true
julia> isa(Real, Type{Float64})
false
julia> isa(Real, Type{Real})
true
julia> isa(Float64, Type{Real})
false
Другими словами, isa(A, Type{B}) истинно тогда и только тогда, когда A и B – один и тот же объект, и этот объект является типом.
В частности, поскольку параметрические типы являются инвариантными, у нас есть
julia> struct TypeParamExample{T}
x::T
end
julia> TypeParamExample isa Type{TypeParamExample}
true
julia> TypeParamExample{Int} isa Type{TypeParamExample}
false
julia> TypeParamExample{Int} isa Type{TypeParamExample{Int}}
true
Без параметра Type – это просто абстрактный тип, у которого все объекты типа являются экземплярами:
julia> isa(Type{Float64}, Type)
true
julia> isa(Float64, Type)
true
julia> isa(Real, Type)
true
Любой объект, который не является типом, не является экземпляром Type:
julia> isa(1, Type)
false
julia> isa("foo", Type)
false
Хотя Type является частью иерархии типов Julia, как и любой другой абстрактный параметрический тип, он не часто используется за пределами сигнатур методов, кроме некоторых особых случаев. Ещё один важный случай использования Type – уточнение типов полей, которые в противном случае были бы описаны менее точно, например, как DataType в примере ниже, где конструктор по умолчанию может привести к проблемам производительности в коде, зависящем от точного обернутого типа (похоже на параметры абстрактного типа).
julia> struct WrapType{T}
value::T
end
julia> WrapType(Float64) # default constructor, note DataType
WrapType{DataType}(Float64)
julia> WrapType(::Type{T}) where T = WrapType{Type{T}}(T)
WrapType
julia> WrapType(Float64) # sharpened constructor, note more precise Type{Float64}
WrapType{Type{Float64}}(Float64)
Псевдонимы типов
Иногда удобно ввести новое имя для уже выразимого типа. Это можно сделать с помощью простого оператора присваивания. Например, UInt является псевдонимом для UInt32 или UInt64 в зависимости от размера указателей на данной системе:
# 32-bit system: julia> UInt UInt32 # 64-bit system: julia> UInt UInt64
Это достигается с помощью следующего кода в base/boot.jl:
if Int === Int64
const UInt = UInt64
else
const UInt = UInt32
end
Конечно, это зависит от того, к чему Int является псевдонимом – но это предварительно определено как правильный тип – либо Int32, либо Int64.
(Обратите внимание, что в отличие от Int, Float не существует как псевдоним для конкретного размера AbstractFloat. В отличие от целочисленных регистров, где размер Int отражает размер собственного указателя на данной машине, размеры регистров с плавающей точкой определяются стандартом IEEE-754.)
Операции над типами
Так как типы в Julia – это сами по себе объекты, обычные функции могут работать с ними. Некоторые функции, которые особенно полезны для работы с типами или изучения типов, уже были представлены, например, оператор <:, который указывает, является ли его левосторонний операнд подтипом его правостороннего операнда.
Функция isa проверяет, принадлежит ли объект данному типу, и возвращает true или false:
julia> isa(1, Int) true julia> isa(1, AbstractFloat) false
Функция typeof, уже используемая в примерах по всей документации, возвращает тип своего аргумента. Поскольку, как отмечалось выше, типы – это объекты, у них тоже есть типы, и мы можем спросить, какими они являются:
julia> typeof(Rational{Int})
DataType
julia> typeof(Union{Real,String})
Union
Что, если мы повторим процесс? Каков тип типа типа? Как оказалось, типы – это все составные значения, а значит, у всех них тип DataType:
julia> typeof(DataType) DataType julia> typeof(Union) DataType
DataType – это его собственный тип.
Другая операция, которая применяется к некоторым типам, это supertype, которая раскрывает супертип типа. Только объявленные типы (DataType) имеют однозначные супертипы:
julia> supertype(Float64) AbstractFloat julia> supertype(Number) Any julia> supertype(AbstractString) Any julia> supertype(Any) Any
Если применить supertype к другим объектам типов (или не объектам типов), возникает MethodError:
julia> supertype(Union{Float64,Int64})
ERROR: MethodError: no method matching supertype(::Type{Union{Float64, Int64}})
Closest candidates are:
[...]
Настройка красивого вывода
Часто требуется настроить отображение экземпляров типа. Это достигается перегрузкой функции show. Например, предположим, что мы определили тип для представления комплексных чисел в полярной форме:
julia> struct Polar{T<:Real} <: Number
r::T
Θ::T
end
julia> Polar(r::Real,Θ::Real) = Polar(promote(r,Θ)...)
Polar
Здесь мы добавили пользовательскую функцию конструктора, чтобы она могла принимать аргументы различных типов Real и преобразовывать их к общему типу (см. Конструкторы и Преобразование и повышение). (Конечно, нам также пришлось бы определить множество других методов, чтобы он действовал как Number, например, +, *, one, zero, правила повышения и так далее.) По умолчанию экземпляры этого типа отображаются довольно просто, с информацией о имени типа и значениях полей, например, Polar{Float64}(3.0,4.0).
Если мы хотим, чтобы он отображался как 3.0 * exp(4.0im), мы бы определили следующий метод для вывода объекта в заданный объект вывода io (представляющий файл, терминал, буфер и т. д.; см. Сети и потоки):
julia> Base.show(io::IO, z::Polar) = print(io, z.r, " * exp(", z.Θ, "im)")
Более тонкий контроль над отображением объектов Polar возможен. В частности, иногда требуется как развёрнутый многострочный формат вывода, используемый для отображения одного объекта в интерактивной оболочке REPL и других интерактивных средах, так и более компактный однострочный формат, используемый для print или для отображения объекта как части другого объекта (например, в массиве). Хотя по умолчанию функция show(io, z) вызывается в обоих случаях, вы можете определить разный многострочный формат для отображения объекта, перегрузив трёхаргументную форму show, которая принимает MIME-тип text/plain в качестве второго аргумента (см. Ввод/вывод мультимедиа), например:
julia> Base.show(io::IO, ::MIME"text/plain", z::Polar{T}) where{T} =
print(io, "Polar{$T} complex number:\n ", z)
(Обратите внимание, что print(..., z) здесь вызовет метод show(io, z) с двумя аргументами.) В результате:
julia> Polar(3, 4.0)
Polar{Float64} complex number:
3.0 * exp(4.0im)
julia> [Polar(3, 4.0), Polar(4.0,5.3)]
2-element Vector{Polar{Float64}}:
3.0 * exp(4.0im)
4.0 * exp(5.3im)
при этом однострочный формат show(io, z) всё ещё используется для массива значений Polar. Технически, оболочка REPL вызывает display(z) для отображения результата выполнения строки, которая по умолчанию соответствует show(stdout, MIME("text/plain"), z), которая в свою очередь по умолчанию соответствует show(stdout, z), но вы не должны определять новые методы display, если вы не определяете новый обработчик отображения мультимедиа (см. Ввод/вывод мультимедиа).
Кроме того, вы также можете определить методы show для других MIME-типов, чтобы включить более богатое отображение (HTML, изображения и т. д.) объектов в средах, которые поддерживают это (например, IJulia). Например, мы можем определить форматированное отображение HTML объектов Polar, с надстрочными и курсивными элементами, с помощью:
julia> Base.show(io::IO, ::MIME"text/html", z::Polar{T}) where {T} =
println(io, "<code>Polar{$T}</code> complex number: ",
z.r, " <i>e</i><sup>", z.Θ, " <i>i</i></sup>")
Объект Polar затем будет автоматически отображаться с помощью HTML в среде, которая поддерживает отображение HTML, но вы можете вызвать show вручную, чтобы получить HTML-вывод, если хотите:
julia> show(stdout, "text/html", Polar(3.0,4.0))
<code>Polar{Float64}</code> complex number: 3.0 <i>e</i><sup>4.0 <i>i</i></sup>
Рендерер HTML отобразит это как: Polar{Float64} комплексное число: 3,0 e4,0 i
В качестве правила, метод show для однострочного отображения должен печатать допустимое выражение Julia для создания показанного объекта. Когда этот метод show содержит инфиксные операторы, такие как оператор умножения (*) в нашем однострочном методе show для Polar выше, он может не правильно разобрать, когда он выводится как часть другого объекта. Чтобы увидеть это, рассмотрим объект выражения (см. Представление программы), который берёт квадрат конкретного экземпляра нашего типа Polar:
julia> a = Polar(3, 4.0)
Polar{Float64} complex number:
3.0 * exp(4.0im)
julia> print(:($a^2))
3.0 * exp(4.0im) ^ 2
Поскольку оператор ^ имеет более высокий приоритет, чем * (см. Приоритет и ассоциативность операторов), этот вывод не точно отражает выражение a ^ 2, которое должно быть равно (3.0 * exp(4.0im)) ^ 2. Для решения этой проблемы мы должны создать пользовательский метод для Base.show_unquoted(io::IO, z::Polar, indent::Int, precedence::Int), который вызывается внутренней частью объекта выражения при выводе:
julia> function Base.show_unquoted(io::IO, z::Polar, ::Int, precedence::Int)
if Base.operator_precedence(:*) <= precedence
print(io, "(")
show(io, z)
print(io, ")")
else
show(io, z)
end
end
julia> :($a^2)
:((3.0 * exp(4.0im)) ^ 2)
Определённый выше метод добавляет скобки вокруг вызова show, когда приоритет вызываемого оператора не ниже, чем приоритет умножения. Эта проверка позволяет опустить скобки при выводе выражения, которые разбираются правильно без скобок (например, :($a + 2) и :($a == 2)):
julia> :($a + 2) :(3.0 * exp(4.0im) + 2) julia> :($a == 2) :(3.0 * exp(4.0im) == 2)
В некоторых случаях полезно настроить поведение методов show в зависимости от контекста. Это можно достичь с помощью типа IOContext, который позволяет передавать контекстные свойства вместе с обернутым потоком ввода-вывода. Например, мы можем создать более короткое представление в нашем методе show , когда свойство :compact установлено в значение true, возвращаясь к длинному представлению, если свойство false или отсутствует:
julia> function Base.show(io::IO, z::Polar)
if get(io, :compact, false)::Bool
print(io, z.r, "ℯ", z.Θ, "im")
else
print(io, z.r, " * exp(", z.Θ, "im)")
end
end
Это новое компактное представление будет использоваться, когда переданный поток ввода-вывода — это объект IOContext с установленным свойством :compact. В частности, это имеет место при выводе массивов с несколькими столбцами (где горизонтальное пространство ограничено):
julia> show(IOContext(stdout, :compact=>true), Polar(3, 4.0))
3.0ℯ4.0im
julia> [Polar(3, 4.0) Polar(4.0,5.3)]
1×2 Matrix{Polar{Float64}}:
3.0ℯ4.0im 4.0ℯ5.3im
См. документацию IOContext для списка общих свойств, которые могут использоваться для настройки вывода.
"Типы значений"
В Julia вы не можете выполнить диспетчеризацию по значению, например, true или false. Однако вы можете выполнить диспетчеризацию по параметрическим типам, и Julia позволяет вам включать значения "сырых битов" (Типы, Символы, Целые числа, числа с плавающей точкой, кортежи и т. д.) в качестве параметров типа. Типичный пример — параметр размерности в Array{T,N}, где T — это тип (например, Float64), но N — просто значение Int.
Вы можете создавать собственные пользовательские типы, которые принимают значения в качестве параметров, и использовать их для управления диспетчеризацией пользовательских типов. В качестве примера этого подхода давайте введём параметрический тип Val{x}, и его конструктор Val(x) = Val{x}(), который служит обычным способом использования этой техники в случаях, когда вам не нужен более сложный иерархический подход.
Val определён как:
julia> struct Val{x}
end
julia> Val(x) = Val{x}()
Val
Более ничего нет в реализации Val . Некоторые функции в стандартной библиотеке Julia принимают экземпляры Val в качестве аргументов, и вы также можете использовать их для написания собственных функций. Например:
julia> firstlast(::Val{true}) = "First"
firstlast (generic function with 1 method)
julia> firstlast(::Val{false}) = "Last"
firstlast (generic function with 2 methods)
julia> firstlast(Val(true))
"First"
julia> firstlast(Val(false))
"Last"
Для согласованности в Julia, в месте вызова всегда нужно передавать экземпляр Val , а не тип, то есть использовать foo(Val(:bar)) , а не foo(Val{:bar}).
Стоит отметить, что очень легко неправильно использовать параметрические "значения" типы, включая Val; в неблагоприятных случаях вы можете легко значительно ухудшить производительность своего кода. В частности, вы никогда не захотите писать код, как показано выше. Для получения более подробной информации о правильном (и неправильном) использовании Val, пожалуйста, прочтите более подробное обсуждение в разделах о советах по производительности.
© 2009–2024 Jeff Bezanson, Stefan Karpinski, Viral B. Shah, and other contributors
Licensed under the MIT License.
https://docs.julialang.org/en/v1.10/manual/types/