Generic-интерфейсы и перегрузка операторов

Generic-интерфейс позволяет вызывать одно имя, а компилятор сам выбирает нужную конкретную процедуру по типам аргументов.

Generic-интерфейс — именованный блок interface, объединяющий несколько конкретных процедур под общим именем; разрешение перегрузки происходит на этапе компиляции по числу, типам и рангам фактических аргументов.

Зачем язык без классов нуждается в перегрузке

Встроенные процедуры Fortran давно «полиморфны»: abs(x) работает и с целыми, и с вещественными, и с комплексными; sum суммирует массив любого числового типа. Это удобно, и естественно желание давать своим процедурам такую же гибкость. Без перегрузки пришлось бы плодить имена: area_real, area_double, area_int — и заставлять пользователя помнить, какое из них для какого типа. Generic-интерфейс убирает это: вы определяете несколько конкретных процедур и связываете их одним родовым именем. Вызывающий пишет просто area(x), а компилятор по типу x подставляет нужную реализацию. Никаких затрат в рантайме: выбор делается при компиляции, это статический полиморфизм.

Именованный generic-интерфейс

Синтаксис — блок interface имя, внутри которого перечислены module procedure (если процедуры в том же модуле) либо полные сигнатуры внешних процедур.

module norms
  implicit none
  private
  public :: norm                ! родовое имя

  interface norm
    module procedure norm_sp     ! одинарная точность
    module procedure norm_dp     ! двойная точность
  end interface norm

contains

  pure function norm_sp(v) result(r)
    real(4), intent(in) :: v(:)
    real(4) :: r
    r = sqrt(sum(v**2))
  end function norm_sp

  pure function norm_dp(v) result(r)
    real(8), intent(in) :: v(:)
    real(8) :: r
    r = sqrt(sum(v**2))
  end function norm_dp

end module norms

Теперь вызов norm(x) при x типа real(4) уйдёт в norm_sp, а при real(8) — в norm_dp. Снаружи видно только имя norm; конкретные процедуры можно оставить приватными. Это классический приём библиотек: один публичный интерфейс, несколько закрытых реализаций под разные типы. Условие корректности — конкретные процедуры должны различаться так, чтобы компилятор всегда мог однозначно выбрать одну: по типу, виду (kind), рангу или числу аргументов. Две процедуры с идентичным набором аргументов в одном generic недопустимы — возникнет неоднозначность.

Перегрузка операторов

Особая и очень выразительная форма generic-интерфейса — перегрузка операторов. Если вы определили производный тип, скажем кватернион или вектор, естественно складывать их через +, а не через add(a,b). Fortran разрешает это блоком interface operator(+).

module vec3_mod
  implicit none
  private
  public :: vec3, operator(+), operator(*)

  type :: vec3
    real :: x, y, z
  end type vec3

  interface operator(+)
    module procedure vec_add
  end interface

  interface operator(*)
    module procedure vec_scale     ! скаляр * вектор
  end interface

contains

  pure function vec_add(a, b) result(c)
    type(vec3), intent(in) :: a, b
    type(vec3) :: c
    c%x = a%x + b%x
    c%y = a%y + b%y
    c%z = a%z + b%z
  end function vec_add

  pure function vec_scale(s, a) result(c)
    real,       intent(in) :: s
    type(vec3), intent(in) :: a
    type(vec3) :: c
    c%x = s * a%x
    c%y = s * a%y
    c%z = s * a%z
  end function vec_scale

end module vec3_mod

Процедуры, перегружающие бинарный оператор, обязаны быть функциями с двумя аргументами intent(in), возвращающими результат. Теперь код потребителя читается как математика:

type(vec3) :: a, b, c
a = vec3(1.0, 0.0, 0.0)
b = vec3(0.0, 2.0, 0.0)
c = 3.0 * (a + b)        ! сначала vec_add, потом vec_scale

Заметьте: 3.0 * (a + b) компилятор разворачивает в vec_scale(3.0, vec_add(a, b)). Это чистый сахар: операторы — лишь удобная запись вызовов функций, никакой магии в рантайме.

Перегрузка присваивания

Оператор присваивания = для производных типов по умолчанию копирует поэлементно (intrinsic assignment). Иногда этого недостаточно — например, при конверсии из другого типа или при «глубоком» копировании структуры с указателями. Тогда определяют interface assignment(=) через подпрограмму с двумя аргументами: первый intent(out) (приёмник), второй intent(in) (источник).

interface assignment(=)
  module procedure vec3_from_array
end interface

! ...
subroutine vec3_from_array(v, a)
  type(vec3), intent(out) :: v
  real,       intent(in)  :: a(3)
  v%x = a(1); v%y = a(2); v%z = a(3)
end subroutine vec3_from_array

После этого v = [1.0, 2.0, 3.0] вызовет vec3_from_array. В отличие от операторов, присваивание перегружается именно подпрограммой, потому что у него нет «возвращаемого значения» — оно меняет приёмник по месту. Перегрузкой = злоупотреблять не стоит: неожиданное поведение знакомого оператора путает читателя. Применяйте её только когда семантика действительно отличается от поэлементного копирования.

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

Разрешение generic-вызова — целиком работа компилятора на этапе анализа. Встретив norm(x), он перебирает конкретные процедуры в интерфейсе и ищет ту единственную, чьи формальные аргументы совместимы с фактическими по правилам сопоставления (тип, kind, ранг). Если подходит ровно одна — он подставляет прямой вызов этой процедуры. Если ни одной или больше одной — ошибка компиляции. Поэтому generic-полиморфизм ничего не стоит в рантайме: в готовом бинарнике уже зашит конкретный вызов, как если бы вы написали имя реализации вручную. Это принципиально отличается от динамического полиморфизма через class (наследование), где выбор делается во время выполнения через таблицу методов — о нём речь в разделе про ООП.

Generic как имя встроенной функции: расширение языка

Особенно элегантный приём — давать generic-имени то же название, что у встроенной функции, расширяя её на свои типы. Например, вы определили тип «дробь» (рациональное число) и хотите, чтобы abs работал и с ним. Fortran это позволяет: объявив interface abs с module procedure для дроби, вы добавляете новую перегрузку к существующей встроенной abs. Для встроенных типов продолжит работать встроенная реализация, для вашего — ваша. С точки зрения пользователя ваш тип становится «таким же гражданином», как встроенные числа: те же привычные функции, та же запись. Это резко повышает удобство библиотек: математический тип (комплексные числа повышенной точности, интервальная арифметика, автоматическое дифференцирование) можно сделать неотличимым в использовании от встроенного. Тот же принцип применяют к перегрузке +, -, *, / и функций sqrt, sin и прочих — и тогда код, написанный для обычных чисел, почти без изменений работает с вашим типом. На этой идее построены, например, библиотеки автоматического дифференцирования: тип «dual number» перегружает все арифметические операции и элементарные функции так, что обычная расчётная формула попутно вычисляет производную.

Семантика операторов и подводные камни

Перегрузка операторов — мощный, но обоюдоострый инструмент, и важно понимать её семантические границы. Во-первых, перегрузка не меняет приоритет и ассоциативность операторов: a + b * c для ваших типов по-прежнему означает a + (b * c), потому что приоритет фиксирован грамматикой языка. Это хорошо — пользователь не должен гадать, как сгруппируются операции. Во-вторых, перегруженный оператор обязан быть pure-функцией без побочных эффектов, чтобы математическая запись не таила сюрпризов; компилятор и сам склоняет к этому. В-третьих, можно определять и собственные операторы вида .cross. или .dot. — именованные операторы в точках, что бывает удобно для специфических операций (векторное произведение, тензорная свёртка), которым не находится готового символа.

interface operator(.cross.)
  module procedure vec_cross
end interface
! ...
pure function vec_cross(a, b) result(c)
  type(vec3), intent(in) :: a, b
  type(vec3) :: c
  c%x = a%y * b%z - a%z * b%y
  c%y = a%z * b%x - a%x * b%z
  c%z = a%x * b%y - a%y * b%x
end function vec_cross
! применение: n = a .cross. b

Главная опасность перегрузки — злоупотребление. Если оператор делает не то, что подсказывает интуиция (скажем, + для строк означает не конкатенацию, а что-то экзотическое), читатель кода будет постоянно ошибаться. Золотое правило: перегружайте оператор только тогда, когда его смысл для вашего типа очевиден и совпадает с математическим ожиданием. Сложение векторов через + — отлично; «сложение» двух конфигураций через + с непонятной семантикой — плохо. Хорошо перегруженные операторы делают код чище и ближе к предметной области; плохо перегруженные превращают его в ребус. Поэтому в зрелых библиотеках перегрузку применяют сдержанно и документируют семантику каждого оператора так же тщательно, как обычных функций.

Статический полиморфизм и его место рядом с динамическим

Generic-интерфейсы стоит понимать как один из двух видов полиморфизма в Fortran, и осознание различия проясняет, когда какой применять. То, что дают generic-интерфейсы, называется статическим (или ad hoc) полиморфизмом: «одно имя — много реализаций», причём нужная выбирается компилятором по типам аргументов на этапе компиляции. Стоимость в рантайме нулевая — в готовом бинарнике стоит прямой вызов конкретной процедуры, как если бы вы написали её имя руками. Второй вид — динамический полиморфизм через наследование и переменные class, где реализация выбирается во время выполнения по фактическому типу объекта через таблицу методов (его мы разбираем в разделе про ООП). У них разные сильные стороны. Статический полиморфизм идеален, когда набор типов известен заранее и фиксирован: числовые типы разной точности, несколько конкретных структур — компилятор разрешит всё на этапе сборки, без накладных расходов, и это отлично подходит для производительного численного кода. Динамический нужен, когда конкретный тип определяется лишь в рантайме: разнородная коллекция объектов, плагинная архитектура, выбор алгоритма по данным. Многие зрелые библиотеки используют оба: generic-интерфейсы для перегрузки операций по типам чисел и динамический полиморфизм для гибкой архитектуры верхнего уровня. Понимание, что Fortran предлагает оба механизма и что статический бесплатен, а динамический имеет цену косвенного вызова, помогает проектировать код, который и гибок там, где надо, и быстр там, где это критично. Перегрузка операторов, рассмотренная в этом уроке, — чисто статический механизм, и потому её можно смело применять даже в горячих вычислительных путях: a + b для вашего векторного типа компилируется в прямой вызов и не мешает ни inlining, ни векторизации.

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

  • Неоднозначные конкретные процедуры. Две процедуры в одном generic с неразличимыми (по типу/рангу/числу) аргументами — компилятор не сможет выбрать. Каждая должна быть отличима.
  • Перегрузка оператора подпрограммой. Операторы (+, *, -) перегружаются функциями, а присваивание =подпрограммой. Перепутать — ошибка компиляции.
  • Забыть открыть operator(+) в public. Если оператор перегружен в модуле с private по умолчанию, его нужно явно перечислить в public, иначе снаружи перегрузка не подхватится.
  • Чрезмерная перегрузка =. Тихо менять смысл присваивания опасно; делайте это только при реальной необходимости (конверсии, глубокое копирование).

Итоги

  • Generic-интерфейс даёт одно имя для процедур, работающих с разными типами; выбор — на компиляции, без затрат в рантайме.
  • Конкретные процедуры связывают через module procedure внутри interface имя.
  • Операторы перегружают функциями (interface operator(+)), присваивание — подпрограммой (interface assignment(=)).
  • Конкретные процедуры обязаны быть различимы, иначе перегрузка неоднозначна.
  • Это статический полиморфизм — антипод динамическому через class и таблицы методов.
Проверьте себя
1. Когда происходит выбор конкретной процедуры в generic-интерфейсе?
AВо время выполнения через таблицу виртуальных методов
BНа этапе компиляции по типам, видам и рангам аргументов
CПри линковке программы
DСлучайно, по первому совпадению имени
2. Чем перегружают оператор присваивания (=) для производного типа?
AФункцией с одним аргументом intent(in)
BПодпрограммой с приёмником intent(out) и источником intent(in)
CФункцией, возвращающей логическое значение
DЕго нельзя перегрузить в Fortran