Полиморфизм: 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; если shape — shape_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.