☰
︎

Программирование на Athanor – 19 (наследование и полиморфизм)

Цикл: "Программирование на Athanor": Часть 19 (Производные классы и виртуальные методы)
12 октября 2026, понедельник 13:04
trilirium для раздела Блоги

Текст опубликован в личном блоге и его автор не имеет отношения к администрации сайта

Наследование: производные классы и виртуальные методы

В предыдущей статье (18-й в этом цикле) – мы подробно рассматривали простые декларации классов, создание экземпляров этих классов и работу с ними. Рассмотрим теперь и производные классы. Как понятно из названия, они уже создаются не "с чистого листа" – а на основе другого, ранее уже декларированного класса. Для производного класса – этот класс называется "классом-родителем" (или "суперклассом"). Всё, что уже присутствует в суперклассе – и его компоненты, и его методы – автоматически становится доступным и в производных классах: наследуется ими от родителя.

Декларация производного класса отличается от уже рассмотренных нами деклараций классов только одним элементом: перед именем нового класса – ставится имя суперкласса в квадратных скобках:

!! [SuperClassName] NewClassName (ArgA ArgB ArgC ...) : [LocA LocB ...] {

	...

	! MethodA = ( ... ), 

	! MethodB = ( ... ), 

	! MethodC = ( ... ), 

	...

  };

В этом примере, мы декларируем новый класс NewClassName, со своими аргументами (ArgA, ArgB, ArgC, ...), компонентами (LocA, LocB ...) и (внутренними) методами (MethodA, MethodB, MethodC ...). Но, кроме всего этого – в новом классе будет доступно и всё, что уже было в классе SuperClassName: все его компоненты – автоматически становятся компонентами NewClassName, а все его методы – будут полностью применимы и к экземплярам NewClassName. Как результат этого, класс NewClassName наследует весь уже имеющийся функционал от SuperClassName.

Механизм наследования в ООП – изобретён в основном для того, чтобы предоставить программисту возможность явно выразить какой-то общий функционал группы сходных классов. Рассмотрим, например, условную программу – автомобильный симулятор. Практически точно можно сказать, что в ней будет нужен базовый суперкласс (GenericCar), в котором реализована общая функциональность для всех автомобилей в программе. У всех автомобилей есть много общего (топливные баки, двигатель, трансмиссия, четыре колеса ...) – и весь связанный с этим код лучше всего реализовать именно в классе GenericCar, чтобы не писать его многократно. С другой стороны – автомобили, всё-таки, совсем неодинаковые. Даже очень далёкий от автомобильного мира человек – легко отличит тяжёлый грузовик от гоночного болида, и ему даже не потребуется для этого лезть под капот. Для того, чтобы реализовать специфичность конкретных классов автомобилей – и требуются производные от GenericCar классы.

Любой суперкласс может порождать любое количество подклассов: ограничений тут не существует. Глубина вложенности в иерархии классов также практически ничем не ограничена: любой производный класс – сам может быть суперклассом для других производных классов (и так далее, на любую глубину). В языке, однако, нет множественного наследования (производный класс может иметь только один суперкласс).

Приведём (пока, довольно искусственный) пример иерархии классов:

!! Class_1 (A B C) {};

!! [Class_1] Class_12 (K L M N) {};

!! [Class_12] Class_123 (X Y Z) {};

Здесь Class_1 – это корень всей иерархии, Class_12 – производный от него, а Class_123 – производный уже от Class_12. Поэтому, в классе Class_12 – помимо собственных аргументов (K, L, M, N) доступны и аргументы суперкласса (A, B, C), а последнем классе – доступны все перечисленные, как и собственные аргументы (X, Y, Z). Для простоты примера, ни один из этих классов не декларирует, например, свои методы (но если бы они были – они наследовались бы вместе со всем прочим).

А как создать экземпляры этих классов?

Object1 = Class_1 (10, 20, 30);
Object12 = Class_12 (100, 200, 300)::(99, 88, 77, 66);
Object123 = Class_123 ((1001, 2002, 3003)::(909, 808, 707, 606))::(3, 2, 1);

Для корневого Class_1 – сюрпризов нет: мы просто передаём значения трём его компонентам. С производными классами – всё интереснее. Каждый производный класс имеет два набора аргументов: один – свой собственный, а второй – для своего суперкласса! В списке его аргументов – начальным аргументом всегда тоже является список (аргументы для суперкласса), а все прочие уже передаются непосредственно классу. Или (если вспомнить про внутреннюю структуру списков): "головой" (l_head) для списка аргументов будут аргументы для суперкласса, а его "хвостом" (l_tail) – аргументы уже для производного класса. Поэтому, для записи списка аргументов производных классов – удобно использовать бинарную операцию "::", явно конструирующую единый список из "головы" и "хвоста". Именно так мы и делаем. Конечно, всё это чистой воды синтаксическое "украшательство". Оба списка аргументов могут быть записаны и в более традиционном виде (но с операций "::" – всё намного нагляднее):

Object12 = Class_12 ((100, 200, 300), 99, 88, 77, 66);
Object123 = Class_123 (((1001, 2002, 3003), 909, 808, 707, 606), 3, 2, 1);

Ещё один важный нюанс состоит в том, что если существует суперкласс, то аргументы для суперкласса – в вызове должны присутствовать всегда. И даже когда явных аргументов у суперкласса просто нет – необходимо явно передать суперклассу пустое значение () вместо списка аргументов! (В противном случае – аргументом для супекласса будет первый аргумент для производного класса, все остальные также будут переданы производному классу со сдвигом, и в результате получится полный хаос.) Чтобы избежать подобных ошибок – при создании экземпляров производных классов надо быть осторожнее.

Также, нередко полезно проверять содержимое создаваемых объектов. Уже говорилось в предыдущей статье, что вызов x_vars вполне применим и к классам. Однако, для производного класса – он выводит только список его собственных компонент, игнорируя компоненты суперкласса (или суперклассов). Впрочем, нетрудно реализовать более удобный инструмент – но для этого полезно следующее:

super_of (ClassRef)
Ссылка на суперкласс для класса ClassRef (или (), если такого нет)

Полагаю, что делает этот функтор ясно из описания. Например, для трёх наших классов:

  • super_of (!Class_123): вернёт !Class_12;
  • super_of (!Class_12): вернёт !Class_1;
  • super_of (!Class_1): вернёт ().

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

! all_class_vars (ClassRef) = {
	if (super_of (ClassRef) [<>] ())::
		all_class_vars (super_of (ClassRef));

	x_vars (ClassRef);
	};

! dump_object (Object) = Object.all_class_vars (class_of (Object));

Теперь посмотрим полный дамп содержимого трёх созданных объектов:

dump_object (Object1);
dump_object (Object12);
dump_object (Object123);

Что выводит:

  A = 10;
  B = 20;
  C = 30;


  A = 100;
  B = 200;
  C = 300;


  K = 99;
  L = 88;
  M = 77;
  N = 66;


  A = 1001;
  B = 2002;
  C = 3003;


  K = 909;
  L = 808;
  M = 707;
  N = 606;


  X = 3;
  Y = 2;
  Z = 1;

Работа с производными классами, в принципе, не отличается от работы с корневыми – но тут тоже есть свои нюансы. Например, префикс класса ("!!") – также работает и с производными классами. Но для них, он автоматически "подключает" не только сам класс – но и все его суперклассы (вплоть до самого верхнего в иерархии):

  • Class_1!! : (просто префикс для Class_1)
  • Class_12!! : равносильно Class_1!! Class_12!!
  • Class_123!! : равносильно Class_1!! Class_12!! Class_123!!

Примерно также всё и с префиксом объектов. Функтор with (он же, бинарная операция ".") – применим к объектам производных классов. Но для них он (как обычно, временно!) делает свой первый операнд текущим экземпляром не только для своего класса, но и для всех его суперклассов, вплоть до самого "верхнего". Что логично, поскольку все определения суперкласса – доступны и в производных классах. Например:

  • Object1.Expr: делает Object1 текущим экземпляром для класса Class_1;
  • Object12.Expr: делает Object12 текущим экземпляром для классов Class_1 и Class_12;
  • Object123.Expr: делает Object123 текущим экземпляром для классов Class_1, Class_12 и Class_123.

Другими словами, чем глубже производный класс находится в иерархии – тем для большего количества классов его объекты становятся текущими экземплярами. (Что также логично: ведь каждый объект принадлежит не только собственному классу, но и всем его суперклассам, если они есть.) Ещё раз подчеркнём, что все эти изменения – временные, и действуют только на время выполнения (или вычисления) выражения Expr. По его завершении, все текущие экземпляры для всех затронутых классов восстанавливают свои прежние значения!

Все атрибуты класса-родителя (а вместе с ними, и вся его функциональность) – наследуются строго сверху вниз. Важно то, что производный класс может не только заимствовать функциональность от всех своих родителей – но и изменять её (в определённых, строго заданных пределах). В языке присутствует механизм для передачи функциональности "снизу вверх", который он даёт производным классам возможность повлиять на работу своих суперклассов (если это в них заранее предусмотрено): виртуальные методы суперклассов.

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

Чтобы в классе имелись виртуальные методы – их надо сперва декларировать. Это делается так:

!! MyClass (Arg1 Arg2 Arg3 ...) : [Field1 Field2 Field3 ...]
	#{ Virtual1 Virtual2 Virtual3 ... }
	
	{

	...

	! #Virtual1 = { ... },

	! #Virtual2 = { ... },
	
	! #Virtual3 = { ... }

	...

	};

Как здесь показано, декларация виртуальных методов является списком: в фигурных скобках, с префиксом "#", и (как и списки параметров и компонент) без явных разделителей. Она находится после заголовка класса, но до самого тела класса (т.е. списка декларируемых классом внутренних методов и пр). Внутри тела класса – все виртуальные методы уже доступны: их можно вызывать, получать на них ссылки и пр.

Однако, чтобы вызов виртуального метода имел реальный эффект, требуется не только его декларация – но и реализация. В примере выше – эти методы не только декларированы, но и реализованы для MyClass. Реализация виртуального метода синтаксически отличается от обычного метода только одним: перед его именем обязательно ставится префикс "#"! Во всём остальном, синтаксис виртуального метода (как и семантика) такой же, как и у любого другого: он также может иметь параметры, локальные переменные, и (обязательно) имеет тело, описывающее все выполняемые им действия.

Главное отличие виртуальных методов от "традиционных" – состоит в том, что в любом классе, производном от MyClass, они могут иметь и собственную реализацию (обычно отличающуюся от реализации в суперклассе). Декларация виртуального метода должна быть только одной – но вот реализаций может быть столько, сколько имеется производных классов. Производный класс может переопределять любой унаследованный от предков виртуальный метод – хотя, он может этого и не делать. В последнем случае, он просто наследует реализацию этого метода от своего суперкласса. Разумеется, каждый производный класс может переопределять унаследованный виртуальный метод только один раз: попытки переопределить его несколько раз считаются ошибкой.

Хотя в приведённом примере класс MyClass реализует все свои виртуальные методы – но вообще-то суперкласс и это делать не обязан! Классы, в которых отсутствует явная реализация одного (или более) виртуальных методов – как правило, являются абстрактными. Создание экземпляров (то есть, инстанциация) – для абстрактных классов обычно не рекомендуется. Единственное, для чего эти классы полезны – для создания производных от них (более "конкретных") классов, в которых все декларируемые в суперклассе виртуальные методы уже реализованы.

Тем не менее, сейчас в языке нет явного способа декларировать "абстрактность" какого-то класса: она является не более, чем условностью. Экземпляры любого класса можно создавать (в том числе, и сугубо "абстрактного"). Даже не реализованные явно виртуальные методы – можно вызывать! Ничего страшного при этом не происходит (впрочем, и ничего интересного – тоже): вызванный метод не выполняет никаких действий, и возвращает (). Фактически, всякий виртуальный метод MyVirtual (не реализованный явно) – ведёт себя так, будто в декларировавшем его классе присутствовала такая реализация:

	! #MyVirtual = ()

Тем не менее, на практике всё-таки лучше придерживаться определённой дисциплины: не создавать экземпляры "абстрактных" классов, не вызывать не реализованные явно виртуальные методы и т.д. (Возможно, что в дальнейших версиях языка это уже будет рассматриваться, как явная ошибка.)

На виртуальный метод (как и на любой другой) можно получить ссылку. При косвенном вызове виртуального метода – применяется тот же "механизм виртуализации", что и при прямом. Имеется и предикат, проверяющий метод на "виртуальность":

is_virtual (FuncRef)
Истина (1), если FuncRef – ссылка на виртуальный метод (любого класса)

В противном случае, вызов is_virtual возвращает 0. Если метод является виртуальным, то можно узнать, какой именно класс является для него оригинатором (т.е., где именно он был декларирован):

originator (FuncRef)
Если FuncRef – ссылка на виртуальный метод, то возвращается ссылка на его класс-оригинатор

В противном случае, будет сообщение об ошибке, а результатом будет ().

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

!! Animal (Name) : []
	#{ whatis voice }

	{

	! greet = <: (whatis (), " ", Name, " говорит вам: ", voice (), "\n")

	};

!! [Animal] Dog {

	! #whatis = "Пес",

	! #voice = "Гав-гав!"

	};

!! [Animal] Cat {

	! #whatis = "Кот",

	! #voice = "Мяя-яу!"

	};

!! [Animal] Rat {

	! #whatis = "Крыса",

	! #voice = "(громко пищит)"

	};

!! [Animal] Cow {

	! #whatis = "Корова",

	! #voice = "Муу-уу!!"

	};

MyZoo = (
	Cow ("3орька"),
	Dog ("Бобик"),
	Dog ("Барбос"),
	Cat ("Мурзик"),
	Cat ("Барсик"),
	Rat ("Лариска")
	);

l_loop (animal, MyZoo, Animal!!animal.greet ());

Класс Animal – это (фактически) абстрактный суперкласс для всех описанных далее "животных". Виртуальный метод whatis – определяет видовое имя ("Пёс", "Кот" и пр.), а виртуальный метод voice – заставляет животное "подать голос". Ещё в суперклассе есть метод greet, заставляющий животное вас поприветствовать! При этом, сам по себе этот метод – не виртуальный: в производном классе явно переопределить его нельзя! Однако, он вызывает виртуальные методы whatis и voice – так что, в этом примере все животные приветствуют вас по-разному:

Корова 3орька говорит вам: Муу-уу!!
Пес Бобик говорит вам: Гав-гав!
Пес Барбос говорит вам: Гав-гав!
Кот Мурзик говорит вам: Мяя-яу!
Кот Барсик говорит вам: Мяя-яу!
Крыса Лариска говорит вам: (громко пищит)

Все эти методы дают ожидаемый эффект, учитывающий, что они реализованы для каждого производного класса по-своему. Конечно, это очень тривиальный пример: результатом для всех виртуальных методов – является константная строка. А вот пример поинтереснее: геометрические фигуры (для которых уже кое-что реально вычисляется: периметр и площадь).

` (Абстрактный) геометрический объект `
!! Shape
	# {
	describe	` информация о фигуре, в виде списка `
	perimeter	` вычислить периметр `
	area		` вычислить площадь `
	} {};

` Прямоугольник (со сторонами A и B) `
!! [Shape] Rectangle (A B) {

	! #describe = ['Прямоугольник (' A ' * ' B ')'],

	! #perimeter = 2*(A + B),

	! #area = A*B

	};

` Квадрат (со стороной S) `
!! [Shape] Square (S) {

	! #describe = ['Квадрат [сторона ' S ']'],

	! #perimeter = 4*S,

	! #area = S*S

	};

` Круг (с радиусом R) `
!! [Shape] Circle (R) {

	! #describe = ['Круг [радиус ' R ']'],

	! #perimeter = 2*pi(R),

	! #area = pi(R*R)

	};

` Треугольник (со сторонами A, B, C) `
!! [Shape] Triangle (A B C) {

	! #describe = ['Треугольник (' A ',' B ',' C ')'],

	! #perimeter = A + B + C,

	! #area : [p] = {
		` (формула Герона:) `
		p = (A + B + C) / 2;
		sqr (p*(p - A)*(p - B)*(p - C))
		}

	};

Абстрактный суперкласс Shape представляет "фигуру вообще", обладающую лишь теми свойствами, что у неё есть некоторое описание (describe), и для неё могут быть вычислены периметр (perimeter) и площадь (area). Далее, мы уже реализуем конкретные разновидности геометрических фигур. Мы даже можем для удобства собрать их в список:

Figures = (
	Square ()::(5),
	Rectangle ()::(6, 9),
	Circle ()::(10),
	Triangle ()::(5, 12, 13)
	);

l_loop (figure, Figures,
	<: Shape!! figure. (describe (), ": ",
		"периметр = ", perimeter (), "; ",
		"площадь = ", area (), "\n")
		);

Заметьте, что здесь при создании каждой "фигуры", мы передаём ей начальный аргумент (): исключительно для суперкласса Shape (у которого нет своих аргументов). В результате, получим:

Квадрат [сторона 5]: периметр = 20; площадь = 25
Прямоугольник (6 * 9): периметр = 30; площадь = 54
Круг [радиус 10]: периметр = 62.831853; площадь = 314.15927
Треугольник (5,12,13): периметр = 30; площадь = 30.

Наследование – механизм очень мощный! Абстракции, которые предоставляют суперклассы, полезны в самых разных ситуациях – от работы с файловой системой и сетевыми соединениями, до основанного на "виджетах" интерфейса с графической средой (про который будем говорить предметно уже в следующих статьях).


Теги