Введение в наследование
Обсуждение
В этой лекции введены основные понятия, связанные с наследованием. Оценим сейчас достоинства некоторых введенных соглашений. Дальнейшие комментарии о механизме наследования (в частности, о множественном наследовании) появятся в следующей лекции.
Явное переопределение
Роль предложения redefine состоит в улучшении читаемости и надежности. Компиляторам, на самом деле, оно не нужно, так как в классе может быть лишь один компонент с данным именем, то объявленный в данном классе компонент, имеющий то же имя, что и компонент некоторого предка, может быть только переопределением этого компонента (или ошибкой).
Не следует пренебрегать возможностью ошибки, так как программист может наследовать некоторый класс, не зная всех компонентов, объявленных в его предках. Для избежания этой опасности требуется явно указать каждое переопределение. В этом и состоит основная роль предложения redefine, которое также полезно при чтении класса.
Доступ к предшественнику процедуры
Напомним правило использования конструкции Precursor (...): она может появляться только в переопределяемой версии процедуры.
Этим обеспечивается цель введения этой конструкции: позволить новому определению использовать первоначальную реализацию. При этом возможность явного указания родителя устраняет всякую неопределенность (в частности, при множественном наследовании). Если бы допускался доступ любой процедуры к любому компоненту предков, то текст класса было бы трудно понять, читателю все время приходилось бы обращаться к текстам многих других классов.
Динамическое связывание и эффективность
Можно подумать, что сила механизма динамического связывания приведет во время выполнения к недопустимым накладным расходам. Такая опасность существует, но аккуратное проектирование языка и хорошие методы его реализации могут ее предотвратить.
Дело в том, что динамическое связывание требует несколько большего объема действий во время выполнения. Сравним вызов обычной процедуры в традиционном языке программирования (Pascal, Ada, C, ...)
-
f (x, a, b, c...)
с ОО-формой
-
x.f (a, b, c...)
Разница между этими двумя формами уже была разъяснена при введении понятия класса, для идентификации типа модуля. Но сейчас мы понимаем, что это связано не только со стилем, имеется также различие и в семантике. В форме (1), какой именно компонент обозначает имя f известно статически во время компиляции или, в худшем случае, во время компоновки, если для объединения раздельно откомпилированных модулей используется компоновщик. Однако при динамическом связывании такая информация недоступна статически: для f в форме (2) выбор компонента зависит от объекта, к которому присоединен x во время конкретного выполнения. Каким будет этот тип нельзя (в общем случае) определить по тексту программы, это служит источником гибкости этого ранее разрекламированного механизма.
Предположим вначале, что динамическое связывание реализовано наивно. Во время выполнения хранится копия иерархии классов. Каждый объект содержит информацию о своем типе - вершине в этой иерархии. Чтобы интерпретировать во время выполнения x.f, окружение ищет соответствующую вершину и проверяет, содержит ли этот класс компонент f. Если да, то прекрасно, мы нашли то, что требовалось. Если нет, то переходим к вершине-родителю и повторяем всю операцию. Может потребоваться проделать путь до самого верхнего класса (или нескольких таких классов в случае множественного наследования).
Такая схема все еще применяется с различными оптимизациями во многих реализациях не статически типизированных языков. Она приводит к существенным затратам, снижающим эффективность. Хуже того, эти затраты не прогнозируемы и растут с увеличением глубины структуры наследования, так как алгоритм может постоянно проходить путь до корня иерархии наследования. Это приводит к конфликту между повторным использованием и эффективностью, поскольку упорная работа над повторным использованием приводит к введению дополнительных уровней наследования. Представьте состояние бедного разработчика, который перед добавлением нового уровня наследования должен оценить, как это ударит по эффективности. Нельзя ставить разработчиков ПО перед таким выбором.
Такой подход является одним из главных источников неэффективности реализаций языка Smalltalk. Это также объясняет, почему он (по крайней мере, в коммерческих реализациях) не поддерживает множественного наследования. Причина - в том, что из-за необходимости обходить весь граф, а не одну ветвь, накладные расходы оказываются чрезмерными.
К счастью, использование статической типизации устраняет эти неприятности. При правильно построенной системе типов и алгоритмах компиляции нет никакой нужды перемещаться по структуре наследования во время выполнения. Для ОО-языка со статической типизацией возможные типы x не произвольны, а ограничены потомками исходного типа x, поэтому компилятор может упростить работу системы выполнения, построив массив структурных данных, содержащих всю необходимую информацию. При наличии этих структур данных накладные расходы на динамическое связывание сильно уменьшаются: они сводятся к вычислению индекса и доступу к массиву. Важно не только то, что такие затраты невелики, но и то, что они ограничены константой, и поэтому можно не беспокоиться о рассмотренной выше проблеме соотношения между переиспользуемостью и эффективностью. Будет ли структура наследования в вашей системе иметь глубину 2 или 20, будет ли в ней 100 классов или 10000, максимальные накладные расходы всегда одни и те же. Они не зависят и от того, является ли наследование единичным или множественным.
Оценка накладных расходов
Оказывается, можно грубо оценить потери на накладные расходы для описанных выше методов динамического связывания. Следующие цифры взяты из опытов ISE по использованию динамического связывания (данные получены при отключении объясняемой ниже оптимизации статического связывания).
Для процедуры, которая ничего не делает, т. е. описана как p1 is do end, превышение времени динамического связывания над временем статического связывания (например, над эквивалентной процедурой на C) составляет около 30%.
Это, конечно, оценка сверху, поскольку реальные процедуры что-нибудь да делают. Цена динамического связывания одинакова для всех процедур независимо от времени их выполнения, поэтому, чем больший объем вычислений выполняет процедура, тем меньше относительная доля накладных расходов. Если вместо p1 использовать процедуру, которая выполняет некоторые типичные операции, такую как
p2 (a, b, c: INTEGER) is local x, y do x := a; y := b + c + 1; x := x * y; if x > y then x := x + 1 else x := x - 1 end end
то накладные расходы падают до 15%. Для программы, выполняющей нечто более существенное (например, некоторый цикл) их доля совсем мала.
Статическое связывание как оптимизация
В некоторых случаях главным требованием является эффективность, и даже указанные выше небольшие накладные расходы нежелательны. В этом случае можно заметить, что они не всегда обоснованы. Вызов x.f (a, b, c...) не нуждается в динамическом связывании в следующих случаях:
- f нигде в системе не переопределяется (имеет только одно объявление);
- x не является полиморфной, иначе говоря, не является целью никакого присоединения, источник которого имеет другой тип.
В любом из таких случаев, выявляемых хорошим компилятором, сгенерированный для x.f (a, b, c...) код может быть таким же, как и код, генерируемый компиляторами C, Pascal, Ada или Fortran для вызова f (x, a, b, c...). Никакие накладные расходы не потребуются.
Компилятор ISE, являющийся частью окружения, описанного в последней лекции, сейчас выполняет оптимизацию (1), планируется добавить и (2) (анализ (2) является, фактически, следствием механизмов анализа типов, описанных в лекции о типизации).
Хотя (1) интересно и само по себе, непосредственная его польза ограничивается сравнительно низкой стоимостью динамического связывания (см. приведенную выше статистику). Настоящий выигрыш от него непрямой, поскольку (1) дает возможность третьей оптимизации:
- При любой возможности применять автоматическую подстановку кода процедуры.
Такая подстановка означает расширение тела программы текстом вызываемой процедуры в месте ее вызова. Например, для процедуры
set_a (x: SOME_TYPE) is -- Сделать x новым значением атрибута a. do a := x end
компилятор может сгенерировать для вызова s.set_a (some_value) такой же код, какой компилятор Pascal сгенерирует для присваивания s.a := some_value (недопустимое для нас обозначение, поскольку оно нарушает скрытие информации). В этом случае вообще нет накладных расходов, поскольку сгенерированный код не содержит вызова процедуры.
Подстановка кода традиционно рассматривается как оптимизация, которую должны задавать программисты. Ada включает прагму (указание транслятору) inline, C и С++ предлагают аналогичные механизмы. Но этому подходу присущи внутренние ограничения. Хотя для небольшой, статичной программы компетентный программист может сам определить, какие процедуры можно подставлять, для больших развивающихся проектов это сделать невозможно. В этом случае компилятор с приличным алгоритмом определения подстановок будет намного превосходить догадки программистов.
Для каждого вызова, к которому применимо автоматическое статическое связывание (1), ОО-компилятор может определить, основываясь на анализе соотношения между временем и памятью, стоит ли применять автоматическую подстановку кода процедуры (3). Это одна из самых поразительных оптимизаций - одна из причин, по которой можно достичь эффективности произведенного вручную кода Си или Фортрана, а иногда, на больших системах и превзойти ее.
К улучшению эффективности, растущему с увеличением размера и сложности программ, автоматическая подстановка кода добавляет преимущество большей надежности и гибкости. Как уже отмечалось, подстановка кода семантически корректна только для процедуры, которую можно статически ограничить, например, как в случаях (1) и (2). Это не только допустимо, но также вполне согласуется с ОО-методом, в частности, с принципом Открыт-Закрыт, если разработчик на полпути разработки большой системы добавит переопределение некоторого компонента, имевшего к этому моменту только одну реализацию. Если же код процедуры вставляется вручную, то в результате может получиться программа с ошибочной семантикой (поскольку в данном случае требуется динамическое связывание, а вставка кода, конечно, означает статическое связывание). Разработчики должны сосредотачиваться на построении корректных программ, не занимаясь утомительными оптимизациями, которые при выполнении вручную приводят к ошибкам, а на деле могут быть автоматизированы.
Имеются и некоторые другие требования для того, чтобы подстановка кода была корректной, в частности, она применима только к нерекурсивным вызовам. Даже корректную подстановку следует применять при разумном соотношении между временем и памятью: подставляемая процедура должна быть небольшой и должна вызываться небольшое число раз. |
Последнее замечание об эффективности. Опубликованная статистика для ОО-языков показывает, что где-то от 30% до 60% вызовов на самом деле используют динамическое связывание. Это зависит от того, насколько интенсивно разработчики используют специфические свойства методов. В системе ISE это соотношение близко к 60%. С использованием только что описанных оптимизаций платить придется только за динамическое связывание только тех вызовов, которые действительно в нем нуждаются. Для оставшихся динамических вызовов накладные расходы не только малы (ограничены константой), но и логически необходимы, - в большинстве случаев для достижения результата, эквивалентного динамическому связыванию, придется использовать условные операторы ( if ... then ... или case ... of ...), которые могут оказаться дороже приведенного выше простого механизма, основанного на доступе к массивам. Поэтому неудивительно, что ОО-программы, откомпилированные хорошим компилятором, могут соревноваться с написанным вручную кодом на C.
Кнопка под другим именем: когда статическое связывание ошибочно
К этому моменту должен стать понятным главный вывод из изложенных в этой лекции принципов наследования:
Принцип динамического связывания
Если результат статического связывания не совпадает с результатом динамического связывания, то такое статическое связывание семантически некорректно.
Рассмотрим вызов x.r. Если x объявлена типа A, но в процессе вычисления была присоединена к объекту типа B, а в классе B компонент r переопределен, то использование в этом вызове исходной версии r из класса A - это не вопрос выбора, это просто ошибка!
Безусловно, имелись причины для переопределения r. Одной из них могла быть оптимизация, как в случае с компонентом perimeter в классе RECTANGLE, но могло также оказаться, что исходная версия r просто некорректно работает для объектов из B. Рассмотрим, например, эскизно описанный класс BUTTON (КНОПКА), являющийся наследником класса WINDOW (ОКНО) в некоторой оконной системе (кнопки являются специальным видом окон). В этом классе переопределена процедура display, так как изображение кнопки немного отличается от изображения обычного окна (например, нужно показать ее рамку). В этом случае, если w имеет объявленный тип WINDOW, но динамически связана, благодаря полиморфизму, с объектом типа BUTTON, то вызов w.display должен исполняться для "кнопочной" версии! Использование display из класса WINDOW приведет к искажению изображения на экране.
Мы не должны позволить, чтобы нас обманула гибкость системы типов, основанная на наследовании, особенно ее правило совместимости типов, позволяющее объявлять сущность на уровне абстракции более высоком, чем уровень типа присоединенного объекта во время конкретного выполнения. Во время выполнения программы единственное, что имеет значение, - это те объекты, к которым применяются компоненты, а сущности - имена в тексте программы - уже давно забыты. Кнопка под любым именем остается кнопкой, независимо от того, названа ли она в программе кнопкой или присоединена к сущности типа окно.
Это рассуждение можно подкрепить некоторым математическим анализом. Напомним условие корректности процедуры из "Проектирование по контракту: построение надежного ПО" об утверждениях:
{prer (xr) and INV} Bodyr {postr (xr) and INV}.
Для целей нашего обсуждения его можно немного упростить, оставив только часть, относящуюся к инвариантам классов, опустив аргументы и используя в качестве индекса имя класса A:
[A-CORRECT]
{INVA} rA {INVA}
Содержательно это означает, что всякое выполнение процедуры r из класса A сохраняет инвариант этого класса. Предположим теперь, что мы переопределили r в некотором собственном потомке B. Соответствующее свойство будет выполняться, если новый класс корректен:
[B-CORRECT]
{INVB} rB {INVB}
Напомним, что инварианты накапливаются при движении вниз по структуре наследования, так что INVB влечет INVA, но, как правило, не наоборот.
Напомним, например, как RECTANGLE добавляет собственные условия к инварианту класса POLYGON. Другой пример, рассмотренный при изучении инвариантов в "Проектирование по контракту: построение надежного ПО" , это класс ACCOUNT1 с компонентами withdrawals_list и deposits_list ; его собственный потомок ACCOUNT2 добавляет к нему, возможно, по соображениям эффективности, новый атрибут balance для постоянного запоминания текущего баланса счета. К инварианту добавляется новое предложение:
consistent_balance: deposits_listltotal - withdrawals_listltotal = current_balance
Из-за этого, возможно, придется переопределить некоторые из процедур класса ACCOUNT1 ; например, процедура deposit, которая использовалась просто для добавления элемента в список deposits_list, сейчас должна будет модифицировать также balance. Иначе класс просто станет ошибочным. Это аналогично тому, что версия процедуры display из класса WINDOW не является корректной для экземпляра класса BUTTON.
Предположим теперь, что к объекту типа B, достижимому через сущность типа A, применяется статическое связывание. При этом из-за того, что соответствующая версия процедуры rA, как правило, не будет поддерживать необходимый инвариант (как, например, depositACCOUNT1 для объектов типа ACCOUNT2 или displayWINDOW для объектов типа BUTTON ), будет получаться неверный объект (например, объект класса ACCOUNT2 с неправильным полем balance или объект класса BUTTON, неправильно показанный на экране).
Такой результат - объект, не удовлетворяющий инварианту своего класса, т.е. основным, универсальным ограничениям на все объекты такого вида - является одним из самых страшных событий, которые могут случиться во время выполнения программы. Если такая ситуация может возникнуть, то нечего надеяться на верный результат вычисления.
Суммируем: статическое связывание является либо оптимизацией, либо ошибкой. Если его семантика совпадает с семантикой динамического связывания (как в случаях (1) и (2)), то оно является оптимизацией, которую может выполнить компилятор. Если у него другая семантика, то это ошибка.
Подход языка С++ к связыванию
Учитывая широкое распространение и влияние языка С++ на другие языки, нужно разъяснить, как в нем решаются некоторые из обсуждаемых здесь вопросов.
Соглашения, принятые в С++, кажутся странными. По умолчанию связывание является статическим. Чтобы процедура (в терминах С++ - функция или метод) связывалась динамически, она должна быть специально объявлена как виртуальная ( virtual ).
Это означает, что приняты два решения:
- Сделать программиста ответственным за выбор статического или динамического связывания.
- Использовать статическое связывание в качестве предопределенного.
Оба нарушают ОО-разработку ПО, но в различной степени: (1) можно попробовать объяснить, а (2) защищать трудно.
По сравнению с подходом этой книги (1) ведет к другому пониманию того, какие задачи должны выполняться людьми (разработчиками ПО), а какие - компьютерами (более точно, компиляторами). Это та же проблема, с которой мы столкнулись при обсуждении автоматического распределения памяти. Подход С++ продолжает традиции C и дает программисту полный контроль над тем, что случится во время выполнения, будь то размещение объекта или вызов процедуры. В отличие от этого, в духе ОО-технологии стремление переложить на плечи компилятора все утомительные задачи, выполнение которых вручную приводит к ошибкам, и для которых имеются подходящие алгоритмы. В крупном масштабе и на большом промежутке времени компиляторы всегда справятся с работой лучше.
Конечно, разработчики отвечают за эффективность их программ, но они должны сосредотачивать свои усилия на том, что может действительно существенно повлиять на результат: на выборе подходящих структур данных и алгоритмов. За все остальное несут ответственность разработчики языков и компиляторов.
Отсюда и несогласие с решением (1): С++ считает, что статическое связывание, как и подстановка кода, должно определяться разработчиками, а развиваемый в этой книге ОО-подход полагает, что за это отвечает компилятор, который будет сам оптимизировать вызовы. Статическое связывание - это оптимизация, а не выбор семантики.
Для ОО-метода имеется еще одно негативное последствие (1). Всегда при определении процедуры требуется указать политику связывания: является она виртуальной или нет, т.е. будет связываться динамически или статически. Такая политика противоречит принципу Открыт-Закрыт, так как заставляет разработчика с самого начала угадать, что будет переопределяться, а что - нет. Это не соответствует тому, как работает наследование: на практике может потребоваться переопределить некоторый компонент в далеком потомке класса, при проектировании которого нельзя было это предвидеть. При подходе С++, если разработчик исходного класса такого не предусмотрел, то придется снова вернуться к этому классу, чтобы изменить объявление компонента на virtual. При этом предполагается, что исходный текст доступен для модификации. А если его нет, или у разработчика нет права его менять, то вас ожидает горькая участь.
По этим причинам решение (1), требующее, чтобы программисты сами задавали политику связывания, мешает эффективному применению ОО-метода.
Решение (2) - использовать статическое связывание в качестве предопределенного - еще хуже. Очень трудно подобрать доводы в его пользу с точки зрения проектирования языка. Как мы видели, выбор статического связывания всегда приводит к ошибкам, если его семантика отличается от динамического. Поэтому не может быть никаких причин для его выбора в качестве предопределенного.
Одно дело - сделать программистов, а не компиляторы ответственными за оптимизацию в безопасных случаях (т.е. попросить их явно указывать статическое связывание, если они считают, что это корректно), но заставлять их писать нечто специальное, чтобы получить корректную семантику - это совсем другое. Если верно или неверно понятые соображения эффективности начинают брать верх над основополагающим требованием корректности ПО, то что-то не в порядке.
Даже в языке, заставляющем программиста отвечать за выбор политики связывания (такое решение принято в C), предопределенное значение должно быть противоположным. Вместо того, чтобы требовать объявлять динамически связываемые функции виртуальными ( virtual ), язык должен был бы использовать динамическое связывание по умолчанию и разрешить программистам выделять словом static (или каким-нибудь другим) компоненты, для которых они хотели бы запросить оптимизацию, доверив им самим (в традиции C и С++) удостоверяться в том, что она допустима.
Это различие особенно важно для начинающих, которые, естественно, имеют тенденцию доверять значениям по умолчанию. Даже для языка, менее страшного, чем С++, нельзя предполагать, что кто-либо сразу справится со всеми деталями наследования. Ответственный подход к этому должен гарантировать корректную семантику для новичков (и вообще, для разработчиков, начинающих новый проект, которые "хотят чтобы прежде всего он был правильным, а уж затем быстрым"), а затем предоставить возможности оптимизации для тех, кому это требуется и кто хорошо разбирается в предмете.
Имея в виду широко распространенный интерес к "совместимости снизу - вверх", создание комитета для изменения политики связывания в С++, особенно пункта (2), будет тяжелым делом, но стоит попытаться пролить свет на опасность нынешних соглашений. Прискорбно, но подход С++ влияет и на другие языки, например, политика динамического связывания в языке Borland Delphi, продолжающем прежние расширения Паскаля, по сути, та же, что и в С++. Отметим все же, что вышедший из недр С++ язык Java в качестве базового использует динамическое связывание. |
Эти наблюдения позволяют дать некоторый практический совет. Что разработчик может сделать при использовании С++ или иного языка с той же политикой связывания? Самым лучшим для разработчиков, не имеющих возможности переключиться на другие средства или ждать улучшений в этом языке, было бы объявлять все функции как виртуальные и тем самым разрешить их любые переопределения в духе ОО-разработки ПО. (К сожалению, некоторые компиляторы С++ ограничивают число виртуальных функций в системе, но можно надеяться, что эти ограничения будут сняты).
Парадокс этого совета в том, что он возвращает нас назад к ситуации, в которой все вызовы реализуются через динамическое связывание и требуют несколько большего времени выполнения. Иными словами, соглашения (1) и (2) языка С++, предназначенные для улучшения эффективности, в конце концов, если следовать правилу: "корректность прежде всего", срабатывают против этого!
Неудивительно, что эксперты по С++ не советуют использовать "чересчур много" объектной ориентированности. Уолтер Брайт (Walter Bright), автор одного из самых популярных компиляторов С++, пишет в [Bright 1995]:
Иными словами: не прибегайте к использованию ОО-методов. ( В том же тексте отстаивается и "группировка всех кодов инициализации" для локализации ссылки - приглашение нарушить элементарные принципы модульного проектирования, которые, как мы видели, предполагают, что каждый класс должен сам отвечать за все, связанное с его инициализацией.)
В этой лекции предложен другой подход: в первую очередь разработчик ОО-ПО должен быть уверен в том, что семантика вызова всегда будет правильной, а это гарантируется динамическим связыванием. Затем можно использовать достаточно изощренные методы компиляции, чтобы порождать статическое связывание или подстановку кода для тех вызовов, которые, как установлено на основе строгого алгоритмического анализа, не требуют динамического связывания.