Документация Engee

Типы

Системы типов традиционно делятся на две довольно сильно отличающиеся друг от друга категории: статические, в которых тип каждого выражения определяется перед выполнением программы, и динамические, в которых о типах ничего неизвестно до выполнения, пока не станут доступными фактические значения. В языках со статической типизацией объектный подход допускает некоторую гибкость: при написании кода необязательно точно знать типы значений на момент компиляции. Явление, когда один и тот же код может оперировать разными типами, называется полиморфизмом. В классических языках с динамической типизацией весь код является полиморфическим: типы значений ограничиваются только при явной проверке типов или в том случае, если операции с объектами не поддерживаются во время выполнения.

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

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

В терминах системы типов язык Julia можно описать как динамический, номинативный и параметрический. Универсальные типы можно параметризировать, а иерархические связи между типами объявляются явно, а не подразумеваются исходя из структуры. Важной отличительной чертой системы типов Julia является то, что конкретные типы не могут быть подтипами друг друга: все конкретные типы являются конечными, и их супертипами могут быть только абстрактные типы. Хотя на первый взгляд это ограничение может показаться излишне строгим, оно имеет множество положительных эффектов и на удивление малое число недостатков. Оказывается, что возможность наследовать поведение гораздо важнее возможности наследовать структуру, а наследование и того и другого создает серьезные трудности в традиционных объектно ориентированных языках. Кроме того, следует сразу упомянуть еще ряд важных аспектов системы типов Julia.

  • Значения не делятся на объектные и необъектные: все значения в Julia являются истинными объектами, типы которых относятся к единому полностью связанному графу типов. Все узлы этого графа являются равноправными типами.

  • Понятие «тип времени компиляции» в Julia не имеет смысла: единственный тип, который может быть у значения, — это его фактический тип на момент выполнения программы. В объектно ориентированных языках это называется типом времени выполнения, причем вследствие сочетания статической компиляции и полиморфизма это различие имеет существенное значение.

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

  • Как абстрактные, так и конкретные типы могут параметризироваться другими типами. Они также могут параметризироваться символами, значениями, для которых функция isbits возвращает true (в частности, числовыми и логическими значениями, которые хранятся как типы C или структуры (struct) без указателей на другие объекты), а также кортежами таких значений. Если на параметры типа не требуется ссылаться или их не требуется ограничивать, то их можно опускать.

Система типов Julia сделана так, чтобы быть эффективной и выразительной, но в то же время четко организованной, интуитивно понятной и незаметной. Многим программистам на Julia вообще не приходится писать код, в котором типы применяются явным образом. Однако в некоторых случаях объявление типов позволяет сделать код более понятным, простым, быстрым и надежным.

Объявления типов

С помощью оператора :: можно добавлять аннотации типов к выражениям и переменным в коде. Главных причин для этого может быть две:

  1. утверждение, помогающее обеспечить правильную работу программы;

  2. предоставление компилятору дополнительной информации о типах, с помощью которой в некоторых случаях можно улучшить производительность.

При добавлении к выражению, предназначенному для вычисления значения, оператор :: означает «является экземпляром типа». Таким образом, он утверждает, что значение выражения в левой части является экземпляром типа в правой. Если тип справа является конкретным, значение слева должно реализовываться этим типом. Напомним, что все конкретные типы являются конечными, поэтому реализация типа не может быть подтипом другой реализации. Если же тип справа является абстрактным, достаточно, чтобы значение реализовывалось конкретным типом, являющимся подтипом этого абстрактного типа. Если утверждение типа не равно 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

Для применения аннотации 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

Последний момент очень важен: несмотря на то, что выражение 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)
[...]

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

Операции с дробями имеют смысл только для целочисленных значений, поэтому значение параметра T ограничено подтипами Integer, а частное двух целых чисел представляет собой значение на вещественной числовой оси, поэтому любое значение Rational является экземпляром абстракции Real.

Типы кортежей

Кортеж — это абстракция аргументов функции без самой функции. Существенными аспектами аргументов функции являются их порядок и типы. Таким образом, тип кортежа аналогичен параметризованному неизменяемому типу, каждый параметр которого — это тип одного поля. Например, тип кортежа из двух элементов похож на следующий неизменяемый тип.

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, чтобы определить абстрактный тип, включающий все одномерные плотные массивы с элементами любого типа.

Одинарные типы

Неизменяемые составные типы без полей называются одинарными. Выражаясь формально, если

  1. T — это неизменяемый составной тип (определенный с помощью ключевого слова struct) и

  2. из 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 см. в советах по производительности.


1. «Небольшое количество» определяется константой MAX_UNION_SPLITTING, которая в настоящее время имеет значение 4.
2. Одинарные типы применяются в ряде популярных языков, включая Haskell, Scala и Ruby.