Введение

Эта статья призвана помочь и тем, кто уже давно использует Julia, и тем, кто только начинает её изучать. Благо совсем скоро начнётся Зимняя школа Julia, где можно будет познакомиться с этим по-настоящему замечательным языком.

Мой путь к написанию этой статьи лежал через погружение в работу с массивами, векторами и матрицами в Engee – среде расчётов и моделирования, в которой Julia является основным языком. И в сообществе которой лежит код этой статьи.

Синтаксис, похожий на Python и MATLAB, позволял мне спокойно работать над инженерными проектами, совмещая скрипты и визуальное моделирование. Но мне было сложно понять, почему вектор-столбец

v = [1
     2
     3]

- вектор размера (3,), а вектор-строка m = [1 2 3] - матрица размера (1,3).

И зная о том, что Julia - это крайне эффективный для вычислений язык, я допускал классические ошибки:

a = collect(1:10)
for i in a
    ...
end

Ведь в MATLAB это было реализовано именно так (в нём range - это массив):

По прочтении этой статьи вы поймёте, почему не стоит использовать collect там, где это не нужно.

Но потом мне стало интересно, как же range, занимая всего 48 байт, обладает всем функционалом массива в виде индексации, сортировки и пр:

sizeof([2:0.5:100;])  # 1546 байт
sizeof(2:0.5:100)     # 48   байт

И, погрузившись в документацию, благо она переведена на русский, я пошёл искать ответы на то, почему Julia так здорово работает с range.

В итоге «искал медь, а нашёл золото». Оказывается, система типов в Julia действительно очень интересная.

Было бы странно переписывать документацию на Хабр, поэтому

эта статья призвана собрать в одном месте и в общих чертах (но с неочевидными иногда особенностями) описать систему типов и их предназначение в Julia.

А также получить ответы на вопросы, поставленные выше.

Типы в Julia

Несмотря на то, что в документации и многих других статьях про типы пошли по более «идеоматическому» пути, сразу рассказывая про абстрактные типы, я предлагаю пойти по более классическому, «С-подобному» пути, и идти снизу-вверх.

Но перед погружением соберём в одном месте сразу все определения, рассчитывая на то, что читатель знаком с хотя бы некоторыми примерами, которые указаны после символа ?.

  • Примитивный тип: тип, определяемый с помощью ключевого слова primitive type. Объекты примитивного типа имеют заданный фиксированный размер памяти, указанный в определении типа. ?Int64,Bool,Char

  • Составной тип: тип, определяемый с помощью ключевого слова struct. Составные типы состоят из нуля или более полей, ссылающихся на другие объекты (примитивного или составного типа).?Complex,Rational (поля re, im и num, den, соответственно), Tuple

  • Конкретный тип: примитивный или составной тип.

  • Абстрактный тип: тип, определяемый с помощью ключевого слова abstract type. Абстрактные типы не имеют полей, и объекты не могут быть созданы (инстанцированы) на их основе. Кроме того, они не могут быть объявлены дочерними по отношению к конкретному типу. Также к абстрактным типам относятся не конкретные типы.? Number, AbstractFloat

  • Изменяемый тип: составной тип, определяемый с помощью ключевого слова mutable struct. Изменяемые типы могут связывать свои поля с другими объектами, отличными от тех, с которыми они были связаны во время инициализации.? String, Dict

  • Неизменяемый тип: все типы, кроме тех, которые определяются с помощью mutable struct.

  • Параметрический тип: семейство (изменяемых или неизменяемых) составных или абстрактных типов с одинаковыми именами полей и названием типа без учёта типов параметров. Определённый тип затем однозначно идентифицируется по имени параметрического типа и типу (типам) параметра (параметров). ? Rational{Int8}(1,2),см. ниже AbstractArray{T,N}, AbstractDict{K,V}

  • Исходные типы: тип, определение которого содержится в Julia Base или в стандартной библиотеке Julia

  • Битовый тип: примитивный или неизменяемый составной тип, все поля которого являются битовыми типами

  • Синглтон: объект, созданный на основе составного типа, состоящего из нуля полей. ?nothing, missing

  • Контейнер: составной тип (не-обязательно изменяемый), предназначенный для ссылки на переменное количество объектов и предоставляющий методы для доступа, перебора и, в конечном счёте, изменения ссылок на другие объекты.

Примитивный тип

Несмотря на то, что в документации не рекомендуется использовать конструкцию primitive type, я предлагаю начать знакомство с типами именно с примитивных.

Это сделано потому, что здесь мы опустимся на самый низкий уровень, где будет видно то, как итоге будут представлены данные в памяти.

В качестве примера введём «защищённый от помех из космоса» Bool, который заполняет все возможные доступные битовые ячейки либо 0 либо 1.

При создании примитивного типа необходимо явно указывать, сколько бит требуется для хранения этого типа. (В нашем случае 8)

primitive type FilledBool  8 end

function FilledBool(x::Int)
    if iszero(x)
        reinterpret(FilledBool,0b00000000)
    elseif x == 1
        reinterpret(FilledBool,0b11111111)
    else 
        error("В качестве параметров допустимы только 0 и 1")
    end
end 

Base.show(io :: IO, x :: FilledBool) = print(io, bitstring(x))

@show tr = FilledBool(1)
@show fls = FilledBool(0)
println("Regular Bool true: ", bitstring(true))
tr = FilledBool(1) = 11111111
fls = FilledBool(0) = 00000000
Regular Bool true: 00000001

Проверим, является ли наш тип битовым:

isbitstype(FilledBool) #true

В документации говорится, что вместо того, чтобы создавать собственные примитивные типы - лучше делать обёртку над ними в виде составного типа.
Давайте же познакомимся с ним поближе!

Составной тип

Неизменяемый составной тип

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

Но мы пока сосредоточимся на типах.

Пусть у нас есть тип «Гора». Мы указываем две характеристики объектов этого типа:

  • год покорения (год может быть положительным или отрицательным);

  • высота горы (предположим, что все горы выше уровня моря).

В неизменяемых типах после их создания нельзя поменять поля.

struct Mountain
    first_ascent_year::Int16
    height::UInt16
end

Everest = Mountain(1953,8848)
Int(Everest.height)

try
    Everest.height = 9000  # нельзя менять значения полей Mountain
catch e 
e
end
ErrorException("setfield!: immutable struct of type Mountain cannot be changed")

Чтобы подробнее посмотреть на то, как устроена структура, можно воспользоваться функцией:

dump(Everest)
Mountain
  first_ascent_year: Int16 1953
  height: UInt16 0x2290

Каждый тип элемента неизменяемой структуры Mountain является битовым, поэтому тип Mountain является битовым.

@show sizeof(Mountain) # 2 поля по 2 байта = 4
isbitstype(Mountain)
sizeof(Mountain) = 4
true

Рассмотрим случай, когда полями неизменяемой структуры является не битовый тип.

Почему строка из 6 символов имеет размер 8

Строка хранится не как массив элементов Char'ов, а как указатель на массив Char'ов.
Поэтому размер структуры - 8 байт (размер указателя), а размер строки - 6 байт.
(Хотя sizeof(Char)=4, в случае ASCII они будут занимать 1 байт).

struct City
    name::String
end

Moscow = City("Moscow")

Moscow.name

@show sizeof(Moscow)
@show sizeof(Moscow.name)
@show Base.summarysize(Moscow);
sizeof(Moscow) = 8
sizeof(Moscow.name) = 6
Base.summarysize(Moscow) = 22

Если требуется использовать статические строки, то

using StaticStrings
struct StaticCity
    name::StaticString{10}
end
Moscow = StaticCity(static"Moscow"10) # дополняется \0 до 10
@show sizeof(Moscow)
@show sizeof(Moscow.name)
@show Base.summarysize(Moscow);
sizeof(Moscow) = 10
sizeof(Moscow.name) = 10
Base.summarysize(Moscow) = 10

Несмотря на то, что мы не можем изменить строку, этот тип не является битовым.

То есть важно понимать отличие между неизменяемым и битовым типами.

Необычное поведение функции ismutable("123") объясняется здесь.

@show isbitstype(City)
@show isbitstype(StaticCity);
isbitstype(City) = false
isbitstype(StaticCity) = true

Хочется отдельно отметить, что

неизменяемый тип может иметь неизменяемые поля изменяемого типа.

В качестве аналогии:

Пусть у нас есть верёвочка, к которой привязан воздушный шарик, который мы можем изменять - растягивать, надувать, наполнять водой.
Но мы не можем оторвать верёвочку и прикрепить к ней зелёный мяч.

struct Student
    name::String
    grade::UInt8        # класс
    grades::Vector{Int} # оценки
 end
 Alex = Student("Alex", 1, [5,5,5])
 @show sizeof(Alex)  # 8 + 1 + 8 = 17 => 24 округление до x % 8 == 0
struct Student
    name::String
    grade::UInt8        # класс
    grades::Vector{Int} # оценки
 end
 Alex = Student("Alex", 1, [5,5,5])
 @show sizeof(Alex)  # 8 + 1 + 8 = 17 => 24 округление до x % 8 == 0
sizeof(Alex) = 24
24
pointer(Alex.grades)
push!(Alex.grades,4)
Alex.grades # [5, 5, 5, 4]
@show pointer(Alex.grades)
push!(Alex.grades,4)
Alex.grades # [5, 5, 5, 4]
@show pointer(Alex.grades)

Мы меняем элементы вектора, но не указатель на его первый элемент.

# разыменование указателя на вектор (1й элемент вектора)
unsafe_load(pointer(Alex.grades)) 
5

А если же мы захотим поменять не элементы вектора, а указатель на вектор, то произойдёт ошибка.

try
Alex.grades = [1, 2, 3] # связываем с НОВЫМ вектор
catch e
    e
end
ErrorException("setfield!: immutable struct of type Student cannot be changed")

Изменяемый тип

В случае же изменяемого типа мы можем менять поля.

mutable struct MutableStudent
    const name::String
    grade::UInt8        # класс
    grades::Vector{Int} # оценки
end
Peter = MutableStudent("Peter", 1, [5,5,5])
Peter.grade = 2

Но есть возможность делать некоторые поля изменяемой структуры неизменяемыми (константными).
В этом случае, несмотря на то, что структура - изменяемая, это поле не получится поменять.

try
    Peter.name = "Alex"
catch e
    e
end
ErrorException("setfield!: const field .name of type MutableStudent cannot be changed")

Теперь мы можем менять вектор на другой:

@show pointer(Peter.grades)
@show Peter.grades = [2,2,2]
@show pointer(Peter.grades)
pointer(Peter.grades) = Ptr{Int64} @0x000000010a12eea0
Peter.grades = [2, 2, 2] = [2, 2, 2]
pointer(Peter.grades) = Ptr{Int64} @0x000000010c152f00
Ptr{Int64} @0x000000010c152f00
Отличие неизменяемой struct от mutable struct с константными полями.

Несмотря на то, что поля неизменяемой структуры и константные поля изменяемой структуры нельзя менять, есть существенная разница между объектами таких типов с одинаковыми полями.

В случае с неизменяемым типом - объекты с одинаковыми полями это буквально один и тот же объект, так как все объекты с одинаковыми полями будут располагаться по одному адресу.

В случае с mutable struct каждый из объектов с одинаковыми константными полями будет располагаться по своему уникальному адресу.

struct Immutable
    a::Int32
    b::Int32
 end

 mutable struct ConstMutable
    const a::Int32
    const b::Int32
end

im_obj_1 = Immutable(1,2)
im_obj_2 = Immutable(1,2)

const_mut_obj_1 = ConstMutable(1,2)
const_mut_obj_2 = ConstMutable(1,2)
# === означает равенство и значений и адресов в памяти
@show im_obj_1 === im_obj_2  
@show const_mut_obj_1 === const_mut_obj_2
im_obj_1 === im_obj_2 = true
const_mut_obj_1 === const_mut_obj_2 = false

Зачем нужны неизменяемые структуры

Неизменяемые структуры могут быть не такие удобными в плане интерфейса их использования.
Но их преимуществом является размещение «на стеке». В то время как изменяемые структуры обычно хранятся «в куче».

@allocations (a = Immutable(3,4); b = Immutable(3,4)))       # 0  
@allocations (a = ConstMutable(3,4); b = ConstMutable(3,4))) # 2

Однако к этому утверждению не нужно относиться буквально.

Так, например, компилятор может провести оптимизации и не выделять память для изменяемых структур внутри функции, которая будет возвращать число, а не изменяемую структуру:

function foo(x,y)
    obj1 = Immutable(x,y)
    obj2 = Immutable(y,x)
    c = obj1.a + obj2.b
end
function bar(x,y)
    obj1 = ConstMutable(x,y)
    obj2 = ConstMutable(y,x)
    c = obj1.a + obj2.b
end
println(@allocations foo(1,2)) # 0
println(@allocations bar(1,2)) # 0

Абстрактный тип

Для чего нужны абстрактные типы?
Они нужны для того, чтобы:

  • группировать конкретные типы

  • задавать интерфейсы для функций

  • управлять областью создания других классов при помощи параметризации (см. ниже)

Группирование конкретных типов

Благодаря абстрактным типам можно организовывать иерархии типов.

Рассмотрим классический и наиболее понятный тип - Number.

Используя A <: B Мы можем указывать или проверять то, что тип A является подтипом B

Int8 <: Integer || Int16 <: Integer
true
subtypes(Signed)
6-element Vector{Any}:
 BigInt
 Int128
 Int16
 Int32
 Int64
 Int8

Также можно работать и в обратную сторону:
B :>A показывает - что B является надтипом A

А функция supertypes возвращает упорядоченный слева-направо по возрастанию кортеж надтипов

supertypes(Int8)
(Int8, Signed, Integer, Real, Number, Any)

Но более визуально приятным расширением является пакет AbstractTrees, позволяющий получить многим знакомую картинку.

using AbstractTrees
AbstractTrees.children(t::Type) = subtypes(t)
print_tree(Number)
Number
├─ MultiplicativeInverse
│  ├─ SignedMultiplicativeInverse
│  └─ UnsignedMultiplicativeInverse
├─ Complex
└─ Real
   ├─ AbstractFloat
   │  ├─ BigFloat
   │  ├─ BFloat16
   │  ├─ Float16
   │  ├─ Float32
   │  └─ Float64
   ├─ AbstractIrrational
   │  └─ Irrational
   ├─ Integer
   │  ├─ Bool
   │  ├─ Signed
   │  │  ├─ BigInt
   │  │  ├─ Int128
   │  │  ├─ Int16
   │  │  ├─ Int32
   │  │  ├─ Int64
   │  │  └─ Int8
   │  └─ Unsigned
   │     ├─ UInt128
   │     ├─ UInt16
   │     ├─ UInt32
   │     ├─ UInt64
   │     └─ UInt8
   └─ Rational

Однако я рекомендую запустить print_tree(Any) и погрузиться в удивительный мир типов Julia.

Чтобы проверить, указан ли тип как abstract, используйте функцию isabstracttype. Но чтобы понять - является ли тип абстрактным - используйте функцию !isconcretetype(т.е. абстрактный = не конкретный).

Абстрактные типы и множественная диспетчеризация

Заканчивая рассуждения про наши числа, отметим, что логично, что любые два числа можно сложить.

Поэтому в promotion.jl есть следующая строчка:

+(x::Number, y::Number) = +(promote(x,y)...)

(с помощью methods(+) можно посмотреть что с чем и по каким правилам складывается)

Хотя, например, наименьшее общее кратное должно быть определено только для целочисленных или рациональных. Его мы обсудим в самом конце статьи.

И для тех, кто устал от чисел, предлагаю вернуться к нашим баранам перейти к милым животным:

abstract type Pet end
struct Dog <: Pet; name::String end
struct Cat <: Pet; name::String end

function encounter(a::Pet, b::Pet)
    verb = meets(a, b)
    println("$(a.name) встречает $(b.name) и $verb.")
end


meets(a::Dog, b::Dog) = "нюхает"
meets(a::Dog, b::Cat) = "гонится"
meets(a::Cat, b::Dog) = "шипит"
meets(a::Cat, b::Cat) = "мурлычит"

fido = Dog("Рекс")
rex = Dog("Мухтар")
whiskers = Cat("Матроскин")
spots = Cat("Бегемот")

encounter(fido, rex)       
encounter(fido, whiskers)  
encounter(whiskers, rex)   
encounter(whiskers, spots) 
Рекс встречает Мухтар и нюхает.
Рекс встречает Матроскин и гонится.
Матроскин встречает Мухтар и шипит.
Матроскин встречает Бегемот и мурлычит.

Удобство состоит в том, что мы можем не уточнять под каждое животное, как оно здоровается с другим, а сделать общий интерфейс «приветствия» для животных.

meets(a::Pet, b::Pet) = "здоровается"
struct Cow <: Pet; name::String end
encounter(rex,Cow("Бурёнка"))
Мухтар встречает Бурёнка и здоровается.

Не во всех языках это работает так удобно. Подробнее об этом можно посмотреть в видео, из которого был взят этот код.

Data Type

Но перед тем, как перейти к последнему разделу, внесём смуту!

И пока что просто посмотрим на странного зверя - DataType, а как он работает посмотрим в конце статьи.

123 isa Integer # true
Vector isa DataType || Dict isa DataType # false
Function isa DataType # true

Не волнуйтесь, объяснения будут даны.

Параметрические типы

Параметрическими могут быть как составные, так и абстрактные и даже примитивные типы.
Начнём с более очевидной разновидности.

Параметрические составные типы

полезны тогда, когда важно сохранение логики структуры, но тип полей объектов может меняться.

Так, например, можно догадаться, что из себя представляет комплексное число:

struct Complex{T<:Real} <: Number
    re::T
    im::T
end
ci8 = Int8(1)+Int8(2)im
@show typeof(ci8)
sizeof(ci8)
typeof(ci8) = Complex{Int8}
2
cf64 = 1.5 + 2im
@show typeof(cf64)
sizeof(cf64)
typeof(cf64) = ComplexF64
16

Как видим, в зависимости от того, какие параметры мы передавали, получаются объекты разных типов.
Они занимают разное количество памяти и могут работать по-разному.

То есть параметрический тип схож на шаблоны в C++.

Параметрические абстрактные типы

Примером параметрического абстрактного типа может служить AbstractDict

abstract type AbstractDict{K,V} end

Словарь же, в свою очередь является:

mutable struct Dict{K,V} <: AbstractDict{K,V}
    slots::Memory{UInt8}
    keys::Memory{K}
    vals::Memory{V}
    ...
    ...
end

Это нужно для того, чтобы реализовывать эффективные интерфейсы.
Например, набор переменных окруженияENV не являетсяDict, зато является AbstractDict.

При этом важно, чтобы ENV был параметрический AbstractDict{String,String}.
Поэтому параметрические абстрактные типы бывают очень удобными.

ENV isa Dict           # false
ENV isa AbstractDict   # true

НАКОНЕЦ, МАССИВЫ!

И только теперь, дойдя до параметрический абстрактных типов, мы можем понять, что такое массивы.

Несмотря на то, что реализация массивов написана на C, мы можем посмотреть, что из себя представляют

[1,2,3]

[1 2 3;
 4 5 6;
 7 8 9;]

 и rand(3,3,3)

Всё дело в определении этого типа.

   abstract type AbstractArray{T,N} end

T здесь обозначает тип переменных, который будет храниться в массиве, а N- размерность массива. (ndims просто возвращает значение N).

Array <: DenseArray <: AbstractArray # true
4-element Vector{Int8}:
 0
 0
 0
 0
Array{Int8,2}(undef,2,3)
2×3 Matrix{Int8}:
 3  0  0
 0  0  0
Array{Int8,3}(undef,3,3,3)
3×3×3 Array{Int8, 3}:
    [:, :, 1] =
     -64  24    0
      36   1    0
      47   0  -64
    
    [:, :, 2] =
     36  1    0
     47  0  -64
     24  0   36
    
    [:, :, 3] =
     47  0  -64
     24  0   36
      1  0   47

И вот ответ на вопрос, почему range в Julia поддерживает интерфейс массива:

1:0.5:1000 isa StepRangeLen <: AbstractArray  # true

То есть параметрический тип схож на шаблоны в C++.

Но важно понимать особенности типов в Julia

Параметрические типы в Julia являются инвариатными

Инвариантность параметрических типов
Инвариантность параметрических типов
Пояснение к картинке

Здесь вместо Vector стоит указывать DenseVector, так как Vector является конкретным типом, а DenseVector - абстрактным. А наследование в Julia применимо только к абстрактным типам.

DenseVector{Integer} <: DenseVector{Real}    # false
DenseVector{Integer} <: DenseVector{<:Real}  # true 

 Vector{Int}     <: Vector{Real}     # false
 Vector{Int}     <: Vector{<:Real}   # true
 Vector{Complex} <: Vector{<:Real}   # false
 Vector{Complex} <: Vector           # true

Подробнее о том, что такое Vector{<:Real} можно почитать по ссылке.

Так кто же такой DataType?

DataType позволяет понять, является ли тип «объявленным».

  1. Все конкретные типы являются DataType.

  2. Большинство не параметрических типов являются DataType

    abstract type Number end; - является DataType

  3. Если мы указали параметры, то это тоже DataType.

DenseVector{Integer} isa DataType # true
Vector isa DataType               # false

Чтобы понять, что такое DataType - проще отталкиваться от того, что не является DataType.

Union{Int32,Char} isa DataType # false
Vector{<:Real} isa DataType # тоже своего рода "объединение всех векторов, чей тип является подтипом Real"

Подробнее смотрите документацию по DataType и UnionAll.

И как же это всё применяется ?

Множественная диспетчеризация обладает следующим приоритетом:

  1. конкретный тип,

  2. абстрактный тип,

  3. параметрический тип.

Закончим мы тем, что посмотрим на устройство функции наибольшего общего кратного, где есть следующие методы:

#1
function lcm(a::T, b::T) where T<:Integer
#2
function lcm(x::Rational, y::Rational)
#3
lcm(a::Real, b::Real) = lcm(promote(a,b)...)
#4
lcm(a::T, b::T) where T<:Real = throw(MethodError(lcm, (a,b)))
  1. Для случая целочисленных будет вызвана функция 1.

  2. Если же мы передадим рациональные параметры, то вызовется функция 2.

  3. Если мы передадим lcm(2, 2//3),то сначала вызовется функция 3 и произойдёт продвижение типов. После чего вызовется функция 2.

promote(2, 2//3)
# (2//1, 2//3)
  1. Но если мы вызовем lcm(2, 1.5) , то после продвижения типов мы попадём в 4 - "шаблонную" версию, где уже будет вызвана ошибка.

До скорых встреч!

Комментарии (3)


  1. adeshere
    14.02.2025 02:04

    Неизменяемые структуры могут быть не такие удобными в плане интерфейса их использования. Но их преимуществом является размещение «на стеке»

    Я тут крокодил мимо, и, наверно, поэтому не смог разобраться: а в чем преимущество-то? Разве это не должно зависеть от того, как используется структура? Например, если у меня есть глобальная неизменяемая структура, при чем тут стек-то?


    1. igaraja Автор
      14.02.2025 02:04

      function sqrt_sum()
          v = [i^2 for i in 1:100] # ОБЫЧНЫЙ вектор
          sqrt(sum(v))
      end
      @btime sqrt_sum()
      
      #  84.305 ns (1 allocation: 896 bytes)
      #  581.6786054171153

      В свою очередь статический вектор:

      function sqrt_sum()
          s_v = @SVector [i^2 for i in 1:100]   # СТАТИЧЕСКИЙ вектор
          sqrt(sum(s_v))
      end
      @btime sqrt_sum()
      
      #  1.600 ns (0 allocations: 0 bytes)
      #  581.6786054171153

      И мы видим, как в случае для изменяемой структуры (Vector{Int}) происходит выделение памяти в куче, а для неизменяемой - не происходит.

      ismutabletype(Vector{Int})       # true
      
      ismutabletype(SVector{Int,100})  # false

      Но в статье специально указывается, что к этому не нужно относиться буквально. И помимо ссылки, указанной в статье, хочу прикрепить ещё одну - ответ одного из разработчиков языка Julia.


  1. adeshere
    14.02.2025 02:04

    @igaraja, спасибо! Кажется, теперь начал понимать. Текст статьи не рассчитан на мимокрокодилов (строго говоря, автор и не должен об этом думать). Поэтому я и протупил.

    Я просто сам всегда компилирую с оптимизацией скорости, поэтому в моем случае память для сложной константной структуры при вызове функции не аллоцируется вообще никогда. Ни на куче, ни в стеке. Поэтому меня и удивило, что аллокация в стеке - это достоинство. Я просто не сразу сообразил, что речь в статье идет про сравнение плохого варианта с еще более худшим ;-)))

    UPD. Уточнил в дизассемблере. На самом деле иногда все-таки аллоцируется. Если функция просто устроена, и мне не требуется многократный доступ к отдельным полям структуры в разных местах функции, то мой компилятор тоже может разместить ее в стеке (вероятно, чтобы не прыгать по дальним адресам памяти?). Возможно, в противном случае работа с ней через стек становится чересчур накладной? Если, конечно, я правильно в дизассемблере разобрался. Но если в двух словах, то, наверно, в моем случае так: пока я не напишу указания в явном виде, компилятор все это решает на свое усмотрение.

    Вообще, похоже, что тема не так проста, как кажется. Попробую при случае покопать ассемблерный код ;-) Проблема в том, что при разных опциях компилятора и в зависимости от ключевых слов, которыми я сопровождаю объявление этой структуры, ассемблерный код получается абсолютно различный. Двух одинаковых байт не найти ;-) Создатели компилятора явно не предполагали, что юзер туда в этот код полезет копаться ;-))))

    P.S.

    Наверно, эту статью вообще не следовало читать человеку, который и так уже использует максимально эффективный для вычислений язык, и поэтому лишь мимоходом интересуется Julia... Простите, что залез в чужой монастырь :-(((