Типы
Системы типов традиционно делятся на два довольно разных лагеря: статические системы типов, где каждый выражение программы должно иметь тип, вычислимый до выполнения программы, и динамические системы типов, где о типах ничего не известно до времени выполнения, когда доступны фактические значения, обрабатываемые программой. Объектно-ориентированное программирование позволяет некоторую гибкость в статически типизированных языках, позволяя писать код без знания точных типов значений во время компиляции. Возможность писать код, который может работать с различными типами, называется полиморфизмом. Весь код в классических динамически типизированных языках является полиморфным: только явным проверкой типов или при ошибке поддержки операций объектами во время выполнения ограничиваются типы каких-либо значений.
Система типов 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} — это точка, координаты которой являются 64-битными числами с плавающей точкой, а тип 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}может быть представлен компактно и эффективно как немедленная пара 64-битных значений; - Экземпляр
Point{Real}должен иметь возможность содержать любую пару экземпляровReal. Поскольку объекты, являющиеся экземплярамиReal, могут иметь произвольный размер и структуру, на практике экземплярPoint{Real}должен быть представлен как пара указателей на индивидуально выделенныеRealобъекты.
Эффективность, достигаемая за счёт возможности хранения Point{Float64} объектов с немедленными значениями, многократно увеличивается в случае массивов: Array{Float64} может храниться как непрерывный блок памяти 64-битных чисел с плавающей точкой, в то время как Array{Real} должен быть массивом указателей на индивидуально выделенные Real объекты — которые могут быть как упакованными 64-битными числами с плавающей точкой, так и произвольно большими, сложными объектами, которые объявлены реализациями 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)
The type `Point{Float64}` exists, but no method is defined for this combination of argument types when trying to construct it.
[...]
julia> Point{Float64}(1.0, 2.0, 3.0)
ERROR: MethodError: no method matching Point{Float64}(::Float64, ::Float64, ::Float64)
The type `Point{Float64}` exists, but no method is defined for this combination of argument types when trying to construct it.
[...]
Для параметрических типов генерируется только один конструктор по умолчанию, так как его переопределение невозможно. Этот конструктор принимает любые аргументы и преобразует их к типам полей.
Во многих случаях излишне указывать тип объекта 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) The type `Point` exists, but no method is defined for this combination of argument types when trying to construct it. 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
Последний параметр типа кортежа может быть специальным значением 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 используются для представления аргументов, принимаемых методами varargs (см. Функции Varargs).
Специальное значение 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> const Family{T} = Set{T}
Set
julia> Family{Char} === Set{Char}
true
Операции над типами
Поскольку типы в 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}})
The function `supertype` exists, but no method is defined for this combination of argument types.
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. В частности, иногда требуется как подробный многострочный формат вывода, используемый для отображения одного объекта в интерактивной среде, так и более компактный однострочный формат, используемый для 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.11/manual/types/