Типы
Системы типов традиционно делятся на две довольно сильно отличающиеся друг от друга категории: статические, в которых тип каждого выражения определяется перед выполнением программы, и динамические, в которых о типах ничего неизвестно до выполнения, пока не станут доступными фактические значения. В языках со статической типизацией объектный подход допускает некоторую гибкость: при написании кода необязательно точно знать типы значений на момент компиляции. Явление, когда один и тот же код может оперировать разными типами, называется полиморфизмом. В классических языках с динамической типизацией весь код является полиморфическим: типы значений ограничиваются только при явной проверке типов или в том случае, если операции с объектами не поддерживаются во время выполнения.
Система типов в Julia является динамической, но обладает некоторыми преимуществами статических систем: вы можете указывать, что тот или иной объект имеет определенный тип. Это не только помогает создавать более эффективный код, но и, что еще более важно, позволяет глубоко интегрировать в язык диспетчеризацию методов на основе типов аргументов. Диспетчеризация методов подробно рассматривается в главе Методы, но корни она берет в описываемой здесь системе типов.
Если в Julia тип значения не указан, то по умолчанию предполагается, что тип может быть любым. Таким образом, в Julia можно писать самые разные функции, вообще не используя типы явным образом. Однако, если нужно сделать поведение функции более явным, можно постепенно добавлять явные аннотации типов в ранее нетипизированный код. Добавление аннотаций преследует три основные цели: использование эффективного механизма множественной диспетчеризации Julia, повышение удобочитаемости кода и перехват ошибок программиста.
В терминах системы типов язык Julia можно описать как динамический, номинативный и параметрический. Универсальные типы можно параметризировать, а иерархические связи между типами объявляются явно, а не подразумеваются исходя из структуры. Важной отличительной чертой системы типов Julia является то, что конкретные типы не могут быть подтипами друг друга: все конкретные типы являются конечными, и их супертипами могут быть только абстрактные типы. Хотя на первый взгляд это ограничение может показаться излишне строгим, оно имеет множество положительных эффектов и на удивление малое число недостатков. Оказывается, что возможность наследовать поведение гораздо важнее возможности наследовать структуру, а наследование и того и другого создает серьезные трудности в традиционных объектно ориентированных языках. Кроме того, следует сразу упомянуть еще ряд важных аспектов системы типов Julia.
-
Значения не делятся на объектные и необъектные: все значения в Julia являются истинными объектами, типы которых относятся к единому полностью связанному графу типов. Все узлы этого графа являются равноправными типами.
-
Понятие «тип времени компиляции» в Julia не имеет смысла: единственный тип, который может быть у значения, — это его фактический тип на момент выполнения программы. В объектно ориентированных языках это называется типом времени выполнения, причем вследствие сочетания статической компиляции и полиморфизма это различие имеет существенное значение.
-
Типами обладают только значения, но не переменные: переменная — это просто имя, привязанное к значению, хотя для краткости можно говорить «тип переменной» вместо «тип значения, на которое ссылается переменная».
-
Как абстрактные, так и конкретные типы могут параметризироваться другими типами. Они также могут параметризироваться символами, значениями, для которых функция
isbits
возвращает true (в частности, числовыми и логическими значениями, которые хранятся как типы C или структуры (struct
) без указателей на другие объекты), а также кортежами таких значений. Если на параметры типа не требуется ссылаться или их не требуется ограничивать, то их можно опускать.
Система типов Julia сделана так, чтобы быть эффективной и выразительной, но в то же время четко организованной, интуитивно понятной и незаметной. Многим программистам на Julia вообще не приходится писать код, в котором типы применяются явным образом. Однако в некоторых случаях объявление типов позволяет сделать код более понятным, простым, быстрым и надежным.
Объявления типов
С помощью оператора ::
можно добавлять аннотации типов к выражениям и переменным в коде. Главных причин для этого может быть две:
-
утверждение, помогающее обеспечить правильную работу программы;
-
предоставление компилятору дополнительной информации о типах, с помощью которой в некоторых случаях можно улучшить производительность.
При добавлении к выражению, предназначенному для вычисления значения, оператор ::
означает «является экземпляром типа». Таким образом, он утверждает, что значение выражения в левой части является экземпляром типа в правой. Если тип справа является конкретным, значение слева должно реализовываться этим типом. Напомним, что все конкретные типы являются конечными, поэтому реализация типа не может быть подтипом другой реализации. Если же тип справа является абстрактным, достаточно, чтобы значение реализовывалось конкретным типом, являющимся подтипом этого абстрактного типа. Если утверждение типа не равно true, вызывается исключение; в противном случае возвращается значение выражения слева.
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 # в объявлении local
x::Int8 = 10 # в левой части присваивания
и применяется к всей текущей области даже перед объявлением.
Начиная с версии 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 # при вызове foo выдается ошибка
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
Элемент bits означает, сколько битов нужно для хранения типа, а элемент name определяет имя нового типа. Объявить примитивный тип можно и как подтип какого-либо супертипа. Если супертип не указан, то непосредственным супертипом по умолчанию является Any
. Таким образом, приведенное выше объявление Bool
означает, что для хранения логического значения нужно восемь битов, а его непосредственным супертипом является тип Integer
. В настоящее время поддерживаются только размеры, кратные 8 бит, а при использовании размеров, отличных от перечисленных выше, вероятны ошибки LLVM. Поэтому, хотя для представления логического значения достаточно всего одного бита, его нельзя объявить размером менее 8 бит.
Типы Bool
, Int8
и UInt8
имеют идентичные представления в памяти: все они занимают 8 бит. Однако, поскольку система типов 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
.
Совместимость: 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}
— это конкретный тип, эквивалентный тому, который был бы получен, если бы в определении Point
мы заменили T
на 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
Последний момент очень важен: несмотря на то, что выражение |
Иными словами, в терминах теории типов параметры типов в 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)
[...]
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
В случае с таким объявлением Point{T}
является подтипом Pointy{T}
при каждом значении 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
При таком объявлении вместо 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
В качестве примера полезного применения параметрических типов приведем реальное определение неизменяемого типа Rational
в Julia, представляющего точное значение целочисленной дроби (для простоты конструктор опущен).
struct Rational{T<:Integer} <: Real
num::T
den::T
end
Типы кортежей
Кортеж — это абстракция аргументов функции без самой функции. Существенными аспектами аргументов функции являются их порядок и типы. Таким образом, тип кортежа аналогичен параметризованному неизменяемому типу, каждый параметр которого — это тип одного поля. Например, тип кортежа из двух элементов похож на следующий неизменяемый тип.
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 служат для представления аргументов, принимаемых методами с переменным числом аргументов (см. раздел Функции с переменным числом аргументов (Vararg)).
Специальному значению Vararg{T,N}
(используемому в качестве последнего параметра типа кортежа) соответствует ровно N
элементов типа T
. NTuple{N,T}
— это удобный псевдоним для Tuple{Vararg{T,N}}
, то есть типа кортежа, содержащего ровно N
элементов типа T
.
Именованные типы кортежей
Именованные кортежи являются экземплярами типа NamedTuple
, у которого два параметра: кортеж символов с именами полей и тип кортежа с типами полей.
julia> typeof((a=1,b="hello"))
NamedTuple{(:a, :b), Tuple{Int64, String}}
Макрос @NamedTuple
обеспечивает более удобный синтаксис объявления типов NamedTuple
наподобие объектов struct
в форме key::Type
(отсутствие части ::Type
равносильно ::Any
).
julia> @NamedTuple{a::Int, b::String}
NamedTuple{(:a, :b), Tuple{Int64, String}}
julia> @NamedTuple begin
a::Int
b::String
end
NamedTuple{(:a, :b), Tuple{Int64, String}}
Тип NamedTuple
можно использовать как конструктор, принимающий один аргумент в виде кортежа. Создаваемый тип NamedTuple
может быть либо конкретным типом, если указаны оба параметра, либо типом только с именами полей.
julia> @NamedTuple{a::Float32,b::String}((1,""))
(a = 1.0f0, b = "")
julia> NamedTuple{(:a, :b)}((1,""))
(a = 1, b = "")
Если указаны типы полей, аргументы преобразуются. В противном случае типы аргументов используются напрямую.
Параметрические примитивные типы
Примитивные типы также можно объявлять параметрически. Например, указатели представлены как примитивные типы, которые можно было бы объявить в Julia следующим образом.
# 32-разрядная система:
primitive type Ptr{T} 32 end
# 64-разрядная система:
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{T} where T<:Integer
есть удобная краткая форма записи: Array{<: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
означает двухэлементные кортежи, первый элемент которых относится к некоторому подтипу 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) # Не может быть одинарным типом...
false
julia> NoFieldsParam{Int}() isa NoFieldsParam # ...так как имеет...
true
julia> NoFieldsParam{Bool}() isa NoFieldsParam # ...несколько экземпляров.
true
julia> Base.issingletontype(NoFieldsParam{Int}) # В параметризованной форме является одинарным.
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})
равно true тогда и только тогда, когда 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) # Конструктор по умолчанию, обратите внимание на DataType
WrapType{DataType}(Float64)
julia> WrapType(::Type{T}) where T = WrapType{Type{T}}(T)
WrapType
julia> WrapType(Float64) # Специализированный конструктор, обратите внимание на более точный тип Type{Float64}
WrapType{Type{Float64}}(Float64)
Псевдонимы типов
Иногда бывает удобно ввести новое имя для уже выразимого типа. Это можно сделать с помощью простого оператора присваивания. Например, псевдонимом для UInt
может быть UInt32
или UInt64
в зависимости от размера указателей в системе.
# 32-разрядная система:
julia> UInt
UInt32
# 64-разрядная система:
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)
Здесь для массива значений Polar
по-прежнему используется однострочный вариант show(io, z)
. Строго говоря, среда REPL вызывает display(z)
для отображения результата выполнения строки, что по умолчанию соответствует вызову show(stdout, MIME("text/plain"), z)
, а он, в свою очередь, соответствует вызову show(stdout, z)
. Однако вам не следует определять новые методы display
, если только вы не определяете новый обработчик для отображения мультимедийных данных (см. раздел Ввод-вывод мультимедийных данных).
Более того, вы также можете определить методы show
для других типов MIME, чтобы обеспечить отображение информации об объектах с более сложным форматированием (разметкой HTML, изображениями и т. д.) в средах, которые поддерживают такую возможность (например, IJulia). Например, определить отображение информации об объектах Polar
в формате HTML с верхними индексами и курсивным начертанием можно так:
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>")
В результате в средах, поддерживающих HTML, информация об объекте Polar
будет автоматически отображаться с использованием 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>
An HTML renderer would display this as: Polar{Float64}
complex number: 3.0 e^4.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
см. в советах по производительности.