Основы объектно-ориентированного программирования - Бертран Мейер
Шрифт:
Интервал:
Закладка:
К этому моменту должен стать понятным главный вывод из изложенных в этой лекции принципов наследования:
Принцип динамического связывания
Если результат статического связывания не совпадает с результатом динамического связывания, то такое статическое связывание семантически некорректно.
Рассмотрим вызов x.r. Если x объявлена типа A, но в процессе вычисления была присоединена к объекту типа B, а в классе B компонент r переопределен, то использование в этом вызове исходной версии r из класса A - это не вопрос выбора, это просто ошибка!
Безусловно, имелись причины для переопределения r. Одной из них могла быть оптимизация, как в случае с компонентом perimeter в классе RECTANGLE, но могло также оказаться, что исходная версия r просто некорректно работает для объектов из B. Рассмотрим, например, эскизно описанный класс BUTTON (КНОПКА), являющийся наследником класса WINDOW (ОКНО) в некоторой оконной системе (кнопки являются специальным видом окон). В этом классе переопределена процедура display, так как изображение кнопки немного отличается от изображения обычного окна (например, нужно показать ее рамку). В этом случае, если w имеет объявленный тип WINDOW, но динамически связана, благодаря полиморфизму, с объектом типа BUTTON, то вызов w.display должен исполняться для "кнопочной" версии! Использование display из класса WINDOW приведет к искажению изображения на экране.
Мы не должны позволить, чтобы нас обманула гибкость системы типов, основанная на наследовании, особенно ее правило совместимости типов, позволяющее объявлять сущность на уровне абстракции более высоком, чем уровень типа присоединенного объекта во время конкретного выполнения. Во время выполнения программы единственное, что имеет значение, - это те объекты, к которым применяются компоненты, а сущности - имена в тексте программы - уже давно забыты. Кнопка под любым именем остается кнопкой, независимо от того, названа ли она в программе кнопкой или присоединена к сущности типа окно.
Это рассуждение можно подкрепить некоторым математическим анализом. Напомним условие корректности процедуры из лекции 11 об утверждениях:
{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, но, как правило, не наоборот.
Рис. 14.14. Версия родителя может не удовлетворять новому инварианту
Напомним, например, как RECTANGLE добавляет собственные условия к инварианту класса POLYGON. Другой пример, рассмотренный при изучении инвариантов в лекции 11, это класс 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) можно попробовать объяснить, а (2) защищать трудно.
По сравнению с подходом этой книги (1) ведет к другому пониманию того, какие задачи должны выполняться людьми (разработчиками ПО), а какие - компьютерами (более точно, компиляторами). Это та же проблема, с которой мы столкнулись при обсуждении автоматического распределения памяти. Подход С++ продолжает традиции C и дает программисту полный контроль над тем, что случится во время выполнения, будь то размещение объекта или вызов процедуры. В отличие от этого, в духе ОО-технологии стремление переложить на плечи компилятора все утомительные задачи, выполнение которых вручную приводит к ошибкам, и для которых имеются подходящие алгоритмы. В крупном масштабе и на большом промежутке времени компиляторы всегда справятся с работой лучше.
Конечно, разработчики отвечают за эффективность их программ, но они должны сосредотачивать свои усилия на том, что может действительно существенно повлиять на результат: на выборе подходящих структур данных и алгоритмов. За все остальное несут ответственность разработчики языков и компиляторов.
Отсюда и несогласие с решением (1): С++ считает, что статическое связывание, как и подстановка кода, должно определяться разработчиками, а развиваемый в этой книге ОО-подход полагает, что за это отвечает компилятор, который будет сам оптимизировать вызовы. Статическое связывание - это оптимизация, а не выбор семантики.
Для ОО-метода имеется еще одно негативное последствие (1). Всегда при определении процедуры требуется указать политику связывания: является она виртуальной или нет, т.е. будет связываться динамически или статически. Такая политика противоречит принципу Открыт-Закрыт, так как заставляет разработчика с самого начала угадать, что будет переопределяться, а что - нет. Это не соответствует тому, как работает наследование: на практике может потребоваться переопределить некоторый компонент в далеком потомке класса, при проектировании которого нельзя было это предвидеть. При подходе С++, если разработчик исходного класса такого не предусмотрел, то придется снова вернуться к этому классу, чтобы изменить объявление компонента на virtual. При этом предполагается, что исходный текст доступен для модификации. А если его нет, или у разработчика нет права его менять, то вас ожидает горькая участь.