Полиморфизм: class, select type и динамическая диспетчеризация

Полиморфная переменная class хранит объект, чей конкретный тип определяется в рантайме, а вызов метода выбирает реализацию по фактическому типу.

Полиморфизм в Fortran основан на переменных class(T), которые могут содержать значение типа T или любого его наследника; вызов type-bound procedure выбирает реализацию по динамическому типу объекта во время выполнения.

Статический и динамический тип

Обычная переменная type(circle) :: c имеет фиксированный тип — он же статический, он же динамический. Переменная же class(shape) :: s особенная: её статический (объявленный) тип — shape, но динамический (фактический во время выполнения) может быть shape или любым его наследником, например circle. Именно это расхождение порождает полиморфизм. Когда вы вызываете s%area(), компилятор не знает заранее, какой объект окажется в s, поэтому генерирует динамический вызов: в рантайме по фактическому типу выбирается нужная версия метода. Если внутри s лежит circle, отработает circle_area; если shapeshape_area. Это сердце объектного полиморфизма: один и тот же текст вызова ведёт себя по-разному в зависимости от того, что реально лежит в переменной.

use shapes_mod
class(shape), allocatable :: s

allocate(s, source = circle(name="c", radius=2.0))
call s%describe()      ! describe внутри вызовет s%area() = circle_area

deallocate(s)
allocate(s, source = shape(name="flat"))
call s%describe()      ! теперь s%area() = shape_area

Метод describe из базового типа внутри обращается к self%area(). И хотя describe написан один раз и знает только про shape, он вызовет правильную площадь для каждого наследника — потому что area диспетчеризуется динамически. Это и есть выгода: алгоритм пишется в терминах базового типа, а работает корректно со всеми производными.

Полиморфные объекты обязаны быть allocatable или pointer

Полиморфную переменную нельзя объявить «по значению» с фиксированным размером, ведь её фактический тип (а значит и размер) заранее неизвестен. Поэтому class(...)-переменные бывают либо allocatable, либо pointer, либо это передаваемый аргумент процедуры (где конкретный объект приходит снаружи). Создают полиморфный объект через allocate. Особенно полезны две формы: source= копирует и тип, и значение образца; mold= берёт только тип. Для полиморфного allocate можно даже явно указать конкретный тип: allocate(circle :: s).

class(shape), allocatable :: a, b
allocate(a, source = circle(name="orig", radius=1.0))  ! a — circle с данными
allocate(b, mold = a)                                   ! b — circle, но не копия данных
allocate(circle :: a)                                   ! явный конкретный тип

select type: безопасный спуск к конкретному типу

Через переменную class(shape) доступны только компоненты и методы базового типа shape. Если внутри лежит circle, его поле radius напрямую недоступно — статический тип этого не знает. Чтобы безопасно «спуститься» к конкретному типу и получить его специфику, используют конструкцию select type. Она проверяет фактический тип и в соответствующей ветке даёт переменную нужного типа.

subroutine report(s)
  class(shape), intent(in) :: s
  select type (s)
  type is (circle)
    ! здесь s виден как circle — доступен radius
    print *, "Это круг, радиус =", s%radius
  class is (shape)
    ! любой shape, не подошедший под более конкретные ветки
    print *, "Базовая фигура:", trim(s%name)
  class default
    print *, "Неизвестный тип"
  end select
end subroutine report

Различают две формы ветвей. type is (T) срабатывает, когда динамический тип — в точности T. class is (T) срабатывает, когда динамический тип — T или его наследник (и ни одна более точная ветвь не подошла). Ветка class default ловит всё остальное. Внутри ветки type is (circle) имя s временно «получает» тип circle, и его поля становятся видны. Без select type добраться до специфики наследника через полиморфную переменную нельзя — это единственный санкционированный и безопасный способ.

Контейнеры разнородных объектов

Полиморфизм позволяет хранить в одном массиве разнотипные объекты — через массив указателей или производный тип-обёртку. Классический приём — тип с полиморфным аллоцируемым компонентом:

type :: shape_box
  class(shape), allocatable :: item
end type shape_box

type(shape_box) :: zoo(3)
allocate(zoo(1)%item, source = circle(name="c1", radius=1.0))
allocate(zoo(2)%item, source = circle(name="c2", radius=2.0))
allocate(zoo(3)%item, source = shape(name="flat"))

integer :: i
do i = 1, 3
  call zoo(i)%item%describe()   ! у каждого — своя area
end do

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

Как работает под капотом

Полиморфная переменная физически хранит не только данные объекта, но и скрытый указатель на дескриптор типа — структуру, описывающую фактический тип и содержащую таблицу методов (аналог vtable в C++). Вызов s%area() компилятор превращает в косвенный: «возьми из дескриптора объекта s адрес метода area и вызови его». Поэтому динамический вызов чуть дороже статического — это одно лишнее разыменование, но в обмен на гибкость. select type реализуется как сравнение дескриптора типа объекта с дескрипторами перечисленных типов. Здесь и проявляется цена полиморфизма: динамическая диспетчеризация мешает встраиванию (inlining) и потому в горячих внутренних циклах численного кода её избегают, оставляя для высокоуровневой архитектуры (выбор алгоритма, разнородные коллекции), где гибкость важнее микросекунд.

Полиморфные аргументы и unlimited polymorphic

Самый частый и естественный способ применять полиморфизм — это полиморфные аргументы процедур. Когда процедура объявляет аргумент как class(shape), intent(in) :: s, она готова принять объект любого наследника shape и работать с ним через общий интерфейс. Это не требует ни allocatable, ни pointer — конкретный объект приходит снаружи, а его динамический тип может быть любым в пределах иерархии. Именно так пишут обобщённые алгоритмы: функция интегрирования принимает class(integrand), решатель — class(equation), и каждый вызов подставляет нужную реализацию. Существует и предельная форма — unlimited polymorphic, переменная class(*), которая может содержать значение любого типа вообще, включая встроенные. Это аналог «универсальной ссылки» (как void* в C или Object в Java), и используется для по-настоящему обобщённых контейнеров, способных хранить что угодно.

subroutine print_anything(item)
  class(*), intent(in) :: item        ! что угодно
  select type (item)
  type is (integer)
    print *, "целое:", item
  type is (real)
    print *, "вещественное:", item
  type is (character(len=*))
    print *, "строка:", item
  class default
    print *, "некоторый объект"
  end select
end subroutine print_anything

Через class(*) нельзя обратиться ни к одному компоненту или методу напрямую — известно лишь, что «там что-то есть». Любая работа с содержимым идёт только через select type, где вы перечисляете возможные типы. Это даёт максимальную обобщённость ценой максимальной церемонии, поэтому class(*) применяют точечно — для универсальных структур данных (списков, очередей «чего угодно»), а не в обычном коде.

Стоимость полиморфизма и инженерный баланс

Динамический полиморфизм — выразительный инструмент, но в высокопроизводительном численном коде его применяют осознанно, понимая цену. Каждый вызов метода через class-переменную — это косвенный вызов через таблицу методов: лишнее разыменование плюс невозможность встраивания (inlining) тела в место вызова. В обычном высокоуровневом коде эта цена ничтожна. Но во внутреннем цикле, исполняемом миллиарды раз, она становится ощутимой: невозможность inlining мешает компилятору и векторизовать, и оптимизировать соседние вычисления. Поэтому сложилась устойчивая архитектурная практика, которую стоит запомнить. Полиморфизм используют на верхних уровнях программы — там, где выбирается алгоритм, перебираются разнородные объекты, строится гибкая структура расчёта. А горячее вычислительное ядро пишут мономорфным: конкретные типы, прямые вызовы, чистые массивы — всё, что компилятор может агрессивно оптимизировать и векторизовать. Между этими слоями select type служит «шлюзом»: один раз, на входе в горячий участок, определяют конкретный тип и дальше работают с ним без полиморфных накладных расходов.

Этот принцип — частный случай более общего инженерного правила: гибкость и скорость противоречат друг другу, и грамотный дизайн размещает каждую там, где она важнее. Гибкость нужна там, где код меняется и комбинируется (архитектура, конфигурация, разнородные данные); скорость — там, где исполняется основная вычислительная нагрузка. Fortran с его двумя видами полиморфизма (статический через generic, динамический через class) и быстрыми массивами идеально подходит для такого расслоения: верх — объектный и гибкий, низ — числовой и быстрый. Понимание этого баланса отличает зрелый расчётный код от наивного, где полиморфизм либо боятся вовсе, либо суют в каждый цикл, теряя производительность.

Полиморфизм в архитектуре научного кода

Чтобы увидеть полиморфизм в действии, рассмотрим типичную задачу из инженерной практики, где он незаменим. Представьте пакет вычислительной гидродинамики, который должен поддерживать разные граничные условия: стенку (скорость нулевая), вход (заданный поток), выход (свободное вытекание), периодическую границу. Все они должны «уметь применяться» к границе расчётной области, но делают это по-разному. Без полиморфизма пришлось бы городить ветвления: на каждой границе проверять её тип через большой select case и вызывать соответствующий код — громоздко, и при добавлении нового типа граничного условия нужно править все такие места. С полиморфизмом решение элегантно: абстрактный тип boundary_condition с методом apply, и наследники wall_bc, inflow_bc, outflow_bc, каждый со своей реализацией apply. Расчётная область хранит массив указателей class(boundary_condition), и главный цикл просто вызывает bc%apply(...) для каждой границы — нужная реализация выбирается динамически. Добавить новый тип граничного условия — значит написать новый наследник; ни строки в существующем коде менять не надо. Это открытость к расширению при закрытости к изменению — один из фундаментальных принципов хорошей архитектуры. Тот же приём применяют к выбору численной схемы, модели турбулентности, уравнения состояния, формата вывода — всюду, где есть «семейство взаимозаменяемых вариантов одной операции». Полиморфизм превращает жёсткий код с разветвлениями в гибкую, расширяемую структуру, где варианты — это объекты-наследники, а не ветви условного оператора. Именно эта гибкость, наряду с инкапсуляцией и наследованием, делает объектную модель Fortran ценной для крупных научных пакетов, которые годами обрастают новыми моделями и схемами: продуманная полиморфная архитектура позволяет добавлять их аккуратно, не дестабилизируя проверенное ядро. И, как мы подчёркивали, цену динамической диспетчеризации платят осознанно — на уровне выбора граничных условий или схем (где вызовов немного), а не внутри миллиардно-итерационного вычислительного ядра.

Частые ошибки

  • Объявить class(...) без allocatable/pointer вне списка аргументов. Полиморфную локальную переменную нельзя создать «по значению» — нужен allocatable или pointer.
  • Лезть к полю наследника через базовую переменную напрямую. s%radius при class(shape) :: s не скомпилируется; нужен select type с веткой type is (circle).
  • Путать type is и class is. Первое — точное совпадение типа, второе — тип или наследник. Порядок ветвей важен: более конкретные ставят выше.
  • Динамическая диспетчеризация в горячем цикле. Косвенный вызов мешает inlining; в производительном ядре предпочитают мономорфный код, оставляя полиморфизм на верхних уровнях.

Итоги

  • Переменная class(T) имеет статический тип T, но динамический — T или любой наследник.
  • Вызов метода через class-переменную диспетчеризуется по фактическому типу в рантайме.
  • Полиморфные объекты создают через allocate с source=/mold= или явным типом; переменная должна быть allocatable/pointer.
  • select type — единственный безопасный способ добраться до специфики наследника (type is / class is / class default).
  • Под капотом — дескриптор типа с таблицей методов; цена — одно разыменование и потеря inlining.
Проверьте себя
1. Зачем нужна конструкция select type?
AЧтобы ускорить динамическую диспетчеризацию
BЧтобы безопасно определить фактический тип полиморфного объекта и получить доступ к специфике наследника
CЧтобы объявить полиморфную переменную
DЧтобы запретить наследование
2. Почему полиморфную локальную переменную class(shape) объявляют как allocatable или pointer?
AЧтобы сэкономить память
BПотому что её фактический тип и размер неизвестны на этапе объявления
CЧтобы запретить вызывать методы
DЭто требование только для базовых типов