Наследование. Правильное наследование и заменяемость / FAQ C++
Должен ли я скрывать функции-члены, которые были общедоступными в моем базовом классе?
Никогда, никогда, никогда не делайте этого. Никогда. Никогда!
Попытка скрыть (исключить, отозвать, закрыть) унаследованные общедоступные функции-члены – очень распространенная ошибка проектирования. Обычно это происходит из-за путанности мыслей.
Примечание: этот ответ FAQ касается открытого (public
) наследования; закрытое (private
) и защищенное (protected
) наследование отличаются.
Преобразование Derived*
→ Base*
работает нормально; почему не работает Derived**
→ Base**
?
Потому что преобразование Derived**
→ Base**
было бы недопустимым и опасным.
C++ допускает преобразование Derived*
→ Base*
, поскольку объект Derived
является разновидностью объекта Base
. Однако попытка преобразовать Derived**
→ Base**
помечается как ошибка. Хотя эта ошибка может быть неочевидной, тем не менее, это хорошо. Например, если вы могли бы преобразовать Car**
(легковой автомобиль) → Venicle**
(транспортное средство), и если вы могли бы аналогичным образом преобразовать NuclearSubmarine**
(атомная подводная лодка) → Vehicle**
(транспортное средство), вы могли бы назначить эти два указателя и в конечном итоге заставить указатель Car*
(легковой автомобиль) указывать на NuclearSubmarine
(атомную подводную лодку):
class Vehicle {
public:
virtual ~Vehicle() { }
virtual void startEngine() = 0;
};
class Car : public Vehicle {
public:
virtual void startEngine();
virtual void openGasCap();
};
class NuclearSubmarine : public Vehicle {
public:
virtual void startEngine();
virtual void fireNuclearMissile();
};
int main()
{
Car car;
Car* carPtr = &car;
Car** carPtrPtr = &carPtr;
Vehicle** vehiclePtrPtr = carPtrPtr; // Это является ошибкой в C++
NuclearSubmarine sub;
NuclearSubmarine* subPtr = ⊂
*vehiclePtrPtr = subPtr;
// Эта последняя строка заставила бы carPtr указывать на sub!
carPtr->openGasCap(); // Это может вызвать fireNuclearMissile() (запустить ядерную ракету)!
// ...
}
Другими словами, если бы было законно преобразовать Derived**
→ Base**
, Base**
можно было бы разыменовать (передавая Base*
), а Base*
можно было бы заставить указывать на объект другого производного класса, что может вызвать серьезные проблемы для национальной безопасности (кто знает, что произойдет, если вы вызовете функцию-член openGasCap()
на том, что вы считали Car
(автомобилем), но на самом деле это была NuclearSubmarine
(атомная подводная лодка)!!). Попробуйте использовать приведенный выше код и посмотрите, что он делает – в большинстве компиляторов он будет вызывать NuclearSubmarine::fireNuclearMissile()
!
(Кстати, вам нужно будет использовать приведение указателя, чтобы заставить его скомпилировать. Предложение: попробуйте скомпилировать его без приведения указателя, чтобы увидеть, что делает компилятор. Если вы будете в тишине, когда на экране появится сообщение об ошибке, вы должны будете услышать приглушенный голос вашего компилятора, умоляющего вас: «Пожалуйста, не используйте приведение указателей! Приведение указателей не позволяет мне сообщать вам об ошибках в вашем коде, но оно не устраняет ваши ошибки! Приведение типов указателей – зло!». По крайней мере, так говорит мой компилятор.)
(Примечание: этот ответ FAQ касается открытого (public
) наследования; закрытое (private
) и защищенное (protected
) наследование отличаются.)
(Примечание: здесь есть концептуальное сходство между этим запретом и запретом на преобразование Foo**
в const Foo**
.)
Является ли «стоянка легковых автомобилей (Car
)» разновидностью «стоянки транспортных средств (Venicle
)»?
Неа.
Знаю, это звучит странно, но это правда. Вы можете думать об этом как о прямом следствии предыдущего ответа FAQ, или вы можете рассуждать так: если бы этот вид связи был корректен, то кто-то мог бы указать указателем «стоянки транспортных средств (Venicle
)» на «стоянку легковых автомобилей (Car
)», что позволит кому-либо добавить любой вид транспортного средства (Venicle
) на «стоянку легковых автомобилей (Car
)» (при условии, что «стоянка транспортных средств (Venicle
)» имеет функцию-член, такую как add(Vehicle&)
). Другими словами, вы можете припарковать Bicycle
(велосипед), SpaceShuttle
(космический шаттл) или даже NuclearSubmarine
(атомную подводную лодку) на стоянке легковых автомобилей. Конечно, было бы удивительно, если бы кто-то получил доступ к тому, что они считали автомобилем, со стоянки автомобилей (Car
), только чтобы обнаружить, что на самом деле это NuclearSubmarine
(атомная подводная лодка). Ну и дела, интересно, что будет делать метод openGasCap()
??
Возможно, это поможет: контейнер Thing
(вещи) не является разновидностью контейнера Anything
(чего угодно), даже если Thing
является разновидностью Anything
. Тяжело осмыслить; но это правда.
Вам не обязательно должно это нравиться. Но вы должны это принять.
Последний пример, который мы используем в наших учебных курсах ООП / C++: «Мешок с яблоками – это не мешок с фруктами». Если бы мешок с яблоками можно было передать как мешок с фруктами, кто-то мог бы положить в этот мешок банан, даже если он должен содержать только яблоки!
(Примечание: этот ответ FAQ касается открытого (public
) наследования; закрытое (private
) и защищенное (protected
) наследование отличаются.)
Является ли массив производного класса Derived
разновидностью массива класса Base
?
Неа.
Это следствие предыдущего ответа FAQ. К сожалению, из-за этого у вас может быть много проблем. Рассмотрим следующий код:
class Base {
public:
virtual void f(); // 1
};
class Derived : public Base {
public:
// ...
private:
int i_; // 2
};
void userCode(Base* arrayOfBase)
{
arrayOfBase[1].f(); // 3
}
int main()
{
Derived arrayOfDerived[10]; // 4
userCode(arrayOfDerived); // 5
// ...
}
Компилятор считает, что это полностью типобезопасно. Строка 5 преобразует Derived*
в Base*
. Но на самом деле это ужасно плохо: поскольку Derived
больше Base
, арифметика указателей, выполненная в строке 3, неверна: при вычислении адреса для arrayOfBase[1]
компилятор использует sizeof(Base)
, но массив является массивом Derived
, что означает, что адрес, вычисленный в строке 3 (и последующий вызов функции-члена f()
), даже не находится в начале какого-либо объекта! Он попадает в середину объекта Derived
. Предполагая, что ваш компилятор использует обычный подход к виртуальным функциям, он переинтерпретирует int i_
первого объекта Derived
, как если бы он указывал на виртуальную таблицу, он будет следовать за этим «указателем» (что на данном этапе означает, что мы достаем что-то из случайного места в памяти), захватит первые несколько слов в этом месте памяти и интерпретирует их, как если бы они были адресом функции-члена C++, затем загрузит это (случайное место в памяти) в указатель инструкции и начнет захват машинных инструкций из этой области памяти. Шансы на это очень высоки.
Основная проблема в том, что C++ не может отличить указатель на объект от указателя на массив объектов. Естественно, C++ «унаследовал» эту особенность от C.
ПРИМЕЧАНИЕ: если бы мы использовали класс массива (например, std::array<Derived, 10>
из стандартной библиотеки) вместо использования необработанного массива, эта проблема была бы правильно отловлена как ошибка во время компиляции, а не при катастрофе во время выполнения.
(Примечание: этот ответ FAQ касается открытого (public
) наследования; закрытое (private
) и защищенное (protected
) наследование отличаются.)
То, что «массив производного класса Derived
» не является разновидностью «массива базового класса Base
», – это плохо?
Да, массивы – это зло (в этом только доля шутки).
Серьезно, массивы очень тесно связаны с указателями, а с указателями заведомо сложно иметь дело. Но если у вас есть полное представление о том, почему несколько вышеупомянутых часто задаваемых вопросов были проблемой с точки зрения проектирования (например, если вы действительно знаете, почему контейнер Thing
не является разновидностью контейнера Anything
), и если вы думаете, что все остальные, кто будет поддерживать ваш код, так же полностью понимают эти истины объектно-ориентированного проектирования, тогда вы можете свободно использовать массивы. Но если вы похожи на большинство людей, вам следует использовать шаблон класса контейнера, такой как std::array<T, N>
из стандартной библиотеки, а не необработанные массивы фиксированного размера.
(Примечание: этот ответ FAQ касается открытого (public
) наследования; закрытое (private
) и защищенное (protected
) наследование отличаются.)
Circle
(круг) – это разновидность Ellipse
(эллипса)?
Смотря с какой стороны посмотреть. Но только если Ellipse
не гарантирует, что он может асимметрично изменять свой размер.
Например, если у Ellipse
есть функция-член setSize(x, y)
, которая обещает, что ширина объекта width()
будет равна x
, а его высота height()
будет равна y
, Circle
не может быть разновидностью Ellipse
. Проще говоря, если Ellipse
может делать то, что не может сделать Circle
, то Circle
не может быть разновидностью Ellipse
.
Это оставляет два корректных отношения между Circle
и Ellipse
:
- сделайте
Circle
иEllipse
совершенно независимыми классами; - получите
Circle
иEllipse
из базового класса, представляющего «эллипсы, которые не могут обязательно выполнять операцию неравногоsetSize()
».
В первом случае Ellipse
может быть унаследован от класса AsymmetricShape
, а setSize(x,y)
может быть введен в AsymmetricShape
. Однако Circle
можно унаследовать от SymmetricShape
, который имеет функцию-член setSize(size)
.
Во втором случае класс Oval
мог иметь только setSize(size)
, который устанавливает размер и width()
, и height()
. Ellipse
и Circle
могут наследоваться от Oval
. Ellipse
(но не Circle
) может добавить операцию setSize(x,y)
(но остерегайтесь правила скрытия, если для обеих операций используется одно и то же имя функции-члена setSize()
).
(Примечание: этот ответ FAQ касается открытого (public
) наследования; закрытое (private
) и защищенное (protected
) наследование отличаются.)
(Примечание: setSize(x,y)
не является священным. В зависимости от ваших целей можно запретить пользователям изменять размеры Ellipse
, и в этом случае было бы правильным выбором при проектировании не иметь setSize(x,y)
в Ellipse
. Однако в этой серии часто задаваемых вопросов обсуждается, что делать, если вы хотите создать производный класс от уже существующего базового класса, в котором есть «неприемлемый» метод. Конечно, идеальная ситуация – обнаружить эту проблему, когда базовый класс еще не существует. Но жизнь не всегда идеальна…)
Есть ли другие варианты решения дилеммы «Circle
является / не является разновидностью Ellipse
»?
Если вы утверждаете, что все объекты Ellipse
можно сплющить асимметрично, и вы заявляете, что класс Circle
является разновидностью Ellipse
, и вы утверждаете, что Circle
не может быть сжат асимметрично, очевидно, вы должны отозвать одно из своих требований. Вы можете избавиться от Ellipse::setSize(x,y)
, избавиться от отношения наследования между Circle
и Ellipse
или признать, что ваши объекты Circle
не обязательно круглые. Вы также можете полностью избавиться от Circle
, в этом случае окружность – это всего лишь временное состояние объекта Ellipse
, а не постоянное качество объекта.
Вот две наиболее распространенные ловушки, в которые регулярно попадают новички в области ООП / C++. Они пытаются использовать приемы кодирования, чтобы скрыть некорректное проектирование, например, они могут переопределить Circle::setSize(x,y)
, чтобы генерировать исключение, вызвать abort()
, выбрать среднее значение двух параметров или просто ничего не делать. К сожалению, все эти хаки удивят пользователей, поскольку пользователи ожидают width() == x
и height() == y
. Единственное, чего вы не должны делать, – это удивлять своих пользователей.
Если вам важно сохранить отношения наследования «Circle
– это разновидность Ellipse
», вы можете ослабить обещание, данное функцией setSize(x,y)
в Ellipse
. Например, вы можете изменить обещание на «эта функция-член может установить width()
в x
и/или она может установить height()
в y
, или она может ничего не делать». К сожалению, это превращает контракт в дриблинг, поскольку пользователь не может полагаться на какое-либо значимое поведение. Следовательно, вся иерархия становится бесполезной (трудно убедить кого-то использовать объект, если вам приходится пожимать плечами, когда вас спрашивают, что этот объект делает для него).
(Примечание: этот ответ FAQ касается открытого (public
) наследования; закрытое (private
) и защищенное (protected
) наследование отличаются.)
(Примечание: setSize(x,y)
не является священным. В зависимости от ваших целей можно запретить пользователям изменять размеры Ellipse
, и в этом случае было бы правильным выбором при проектировании не иметь setSize(x,y)
в Ellipse
. Однако в этой серии часто задаваемых вопросов обсуждается, что делать, если вы хотите создать производный класс от уже существующего базового класса, в котором есть «неприемлемый» метод. Конечно, идеальная ситуация – обнаружить эту проблему, когда базовый класс еще не существует. Но жизнь не всегда идеальна…)
Но у меня есть докторская степень. по математике, и я уверен, что Circle
(круг) – это разновидность Ellipse
(эллипса)! Означает ли это, что Маршалл Клайн глуп? Или этот C++ тупой? Или это ООП глупое?
На самом деле это не означает ничего из этого. Но я скажу вам, что это на самом деле означает – вам может не понравиться то, что я собираюсь сказать: это означает, что ваше интуитивное представление о «разновидности» ведет вас к неправильным решениям о наследовании. Ваша интуиция лжет вам о том, что на самом деле означает хорошее наследование – перестаньте верить этой лжи.
Послушайте, я получил и ответил на десятки страстных электронных писем на эту тему. Я учил этому сотни раз тысячи профессионалов в области программного обеспечения по всему миру. Я знаю, это противоречит твоей интуиции. Но поверьте мне; ваша интуиция ошибочна, где «ошибочная» означает «заставит вас принимать неверные решения о наследовании в объектно-ориентированном проектировании / программировании».
Вот как принимать правильные решения о наследовании в объектно-ориентированном проектировании / программировании: признать, что объекты производного класса должны быть заменяемыми объектами базового класса. Это означает, что объекты производного класса должны вести себя в соответствии с обещаниями, данными в контракте базового класса. Как только вы поверите в это, и я полностью осознаю, что вы, возможно, еще не поверили, но если будете работать над этим непредвзято, вы увидите, что setSize(x,y)
нарушает эту заменяемость.
Есть три способа решить эту проблему:
- смягчение обещаний, данных
setSize(x,y)
в базовом классеEllipse
, или, возможно, полностью удалите этот метод, рискуя нарушить существующий код, который вызываетsetSize(x,y)
; - усиление обещаний, сделанных
setSize(x,y)
в производном классеCircle
, что на самом деле означает разрешениеCircle
иметь высоту, отличную от ширины – асимметричный круг; хммм. - отбросьте отношения наследования, возможно, полностью избавившись от класса
Circle
(в этом случае окружность будет просто временным состояниемEllipse
, а не постоянным ограничением объекта).
Извините, но другого выбора просто нет.
Вы должны сделать базовый класс слабее (ослабить Ellipse
до такой степени, что он больше не гарантирует, что вы можете установить его ширину и высоту в разные значения), сделать производный класс сильнее (наделить Circle
возможностью быть как симметричным, так и, кхм, асимметричным), или признать, что Circle
не заменяет Ellipse
.
Важно: на самом деле нет другого выбора, кроме трех вышеупомянутых. В частности:
- ПОЖАЛУЙСТА, не пишите и не говорите, что четвертый вариант – унаследовать и
Circle
, иEllipse
от третьего общего базового класса. Это не четвертое решение. Это просто переработка решения № 3: оно работает именно потому, что устраняет отношения наследования междуCircle
иEllipse
. - ПОЖАЛУЙСТА, не пишите и не говорите, что четвертый вариант – запретить пользователям изменять размеры «эллипса». Это не четвертое решение. Это просто переработка решения № 1: оно работает именно потому, что устраняет гарантию того, что
setSize(x,y)
фактически устанавливает ширину и высоту. - ПОЖАЛУЙСТА, не пишите и не говорите, что вы решили, что одно из этих трех является «лучшим» решением. Это покажет, что вы упустили весь смысл этого ответа FAQ, в частности, что плохое наследование неуловимо, но, к счастью, у вас есть три (не один; не два; а три) возможных способа выкопать себя из этого. Поэтому, когда вы столкнетесь с плохим наследованием, попробуйте все три этих метода и выберите лучший, возможно, «наименее плохой» из трех. Не выбрасывайте сразу два из этих инструментов: попробуйте их все.
(Примечание: этот ответ FAQ касается открытого (public
) наследования; закрытое (private
) и защищенное (protected
) наследование отличаются.)
(Примечание: некоторые люди правильно указывают, что константныйCircle
может быть заменен на константныйEllipse
. Это правда, но на самом деле это не четвертый вариант: на самом деле это просто частный случай варианта № 1, поскольку он работает именно потому, что константный Ellipse
не имеет метода setSize(x,y)
.)
Может быть, тогда Ellipse
должен наследоваться от Circle
?
Если Circle
является базовым классом, а Ellipse
– производным классом, вы столкнетесь с совершенно новым набором проблем. Например, предположим, что у Circle
есть метод radius()
. Тогда у Ellipse
также должен быть метод radius()
, но это не имеет особого смысла: что вообще означает, что возможно асимметричный эллипс имеет радиус?
Если вы преодолеете это препятствие, например, если Ellipse::radius()
вернет среднее значение по большой и малой осям или что-то еще, тогда возникнет проблема с соотношением между radius()
и area()
. Предположим, в Circle
есть метод area()
, который обещает вернуть число пи, умноженное на квадрат любого значения, возвращаемого radius()
. Тогда либо Ellipse::area()
не вернет правильную площадь эллипса, либо вам придется встать на голову, чтобы получить radius()
, возвращающий что-то, соответствующее приведенной выше формуле.
Даже если вы пройдете мимо этого, например, если Ellipse::radius()
вернет квадратный корень из площади эллипса, деленной на число пи, вы застрянете на методе circumference()
(длина окружности). Предположим, у Circle
есть метод circumference()
, который обещает вернуть в двойку, умноженную на число пи, умноженное на то, что возвращает radius()
. Теперь вы застряли: нет никакого способа заставить все эти ограничения работать для Ellipse
: класс Ellipse
должен лгать о своей площади, своей длине окружности или об обоих параметрах.
Итог: вы можете унаследовать всё от чего угодно, если методы в производном классе соблюдают обещания, сделанные в базовом классе. Но вы не должны использовать наследование только потому, что вам это нравится, или просто потому, что вы хотите повторно использовать код. Вы должны использовать наследование (а) только в том случае, если методы производного класса могут выполнять все обещания, данные в базовом классе, и (б) только в том случае, если вы не думаете, что запутаете своих пользователей, и (в) только если есть что-то, что можно получить, используя наследование – какое-то реальное, измеримое улучшение во времени, деньгах или рисках.
Но моя проблема не имеет ничего общего с кругами и эллипсами, так какая мне польза от этого глупого примера?
А вот в чем проблема. Вы думаете, что пример с Circle
/Ellipse
– просто глупый пример. Но на самом деле ваша проблема – изоморфизм этого примера.
Меня не волнует, в чем заключается ваша проблема с наследованием, но все (да, все) плохие наследования сводятся к примеру с «круг не является разновидностью эллипса».
Вот почему: у плохих наследований всегда есть базовый класс с дополнительной возможностью (часто с дополнительной функцией-членом или двумя; иногда с дополнительным обещанием, сделанным одной или комбинацией функций-членов), которые производный класс не может удовлетворить. Вам нужно либо ослабить базовый класс, либо усилить производный класс, либо устранить предлагаемые отношения наследования. Я видел много-много-много этих плохих предложений наследования, и поверьте мне, все они сводятся к примеру с Circle
/Ellipse
.
Следовательно, если вы действительно понимаете пример Circle
/Ellipse
, вы сможете распознать плохое наследование повсюду. Если вы не понимаете, что происходит с проблемой Circle
/Ellipse
, высока вероятность того, что вы сделаете очень серьезные и очень дорогостоящие ошибки наследования.
Печально, но факт.
(Примечание: этот ответ FAQ касается открытого (public
) наследования; закрытое (private
) и защищенное (protected
) наследование отличаются.)
Как это могло «зависеть от обстоятельств»?! Разве такие термины, как Circle
(круг) и Ellipse
(эллипс) не определены математически?
Неважно, что эти термины определены математически. Это не относится к делу – вот почему «это зависит от обстоятельств».
Первый шаг в любом рациональном обсуждении – определение терминов. В этом случае первым шагом является определение терминов Circle
(круг) и Ellipse
(эллипс). Хотите верьте, хотите нет, но самые горячие разногласия по поводу того, должен ли класс Circle
наследоваться от класса Ellipse
, вызваны несовместимыми определениями этих терминов.
Ключевой вывод состоит в том, чтобы забыть математику и «реальный мир» и вместо этого принять в качестве окончательных единственные определения, которые имеют отношение к ответу на вопрос: сами классы. Возьмите Ellipse
. Вы создали класс с этим именем, поэтому единственный окончательный судья того, что вы имели в виду под этим термином, – это ваш класс. Люди, которые пытаются смешать «реальный мир» с обсуждением, безнадежно сбиваются с толку и часто вступают в горячие (и, к сожалению, бессмысленные) споры.
Поскольку так много людей просто не понимают этого, вот пример. Предположим, ваша программа говорит class Foo : public Bar {...}
. Это определяет то, что вы подразумеваете под термином Foo
: одно, окончательное, недвусмысленное и точное определение Foo
дается путем объединения общедоступных частей Foo
с общедоступными частями его базового класса Bar
. Теперь предположим, что вы решили переименовать Bar
в Ellipse
и Foo
в Circle
. Это означает, что вы (да, вы; не «математика»; не «история»; не «приоритеты», ни Евклид, ни Эйлер, ни какой-либо другой известный математик; а просто вы) определили значение термина «Circle
» в своей программе. Если вы определили его таким образом, который не соответствовал интуитивному представлению людей о кругах, то вам, вероятно, следовало выбрать лучшую метку для своего класса, но, тем не менее, ваше определение является единственным, окончательным, недвусмысленным и точным определением термина Circle
в вашей программе. Если кто-то другой вне вашей программы определяет тот же термин по-другому, это другое определение не имеет отношения к вопросам о вашей программе, даже если «кто-то другой» – это Евклид. Внутри вашей программы вы определяете термины, а термин Circle
определяется вашим классом с именем Circle
.
Проще говоря, когда мы задаем вопросы о словах, определенных в вашей программе, мы должны использовать ваши определения этих терминов, а не определения Евклида. Вот почему окончательный ответ на вопрос: «это зависит от обстоятельств». Это зависит от того, может ли то, что ваша программа называет Circle
, правильно заменить то, что ваша программа называет Ellipse
, и это зависит от того, как ваша программа определяет эти термины. Смешно и ошибочно использовать определение Евклида при попытке ответить на вопросы о ваших классах в вашей программе; мы должны использовать ваши определения.
Когда кого-то это раздражает, я всегда предлагаю поменять метки на термины, которые не имеют заранее определенного значения, например Foo
и Bar
. Поскольку эти термины не вызывают вспоминаний о каких-либо математических связях, люди, естественно, обращаются к определению класса, чтобы точно выяснить, что имел в виду программист. Но как только мы переименовываем класс с Foo
в Circle
, некоторые люди внезапно думают, что они могут контролировать значение термина; они неправы и глупы. Определение термина по-прежнему определяется исключительно самим классом, а не какой-либо внешней сущностью.
Следующий вывод: наследование означает «заменяемо». Это не означает «является» (поскольку это неправильное определение) и не означает «является разновидностью» (также плохое определение). Заменяемость четко определена: чтобы быть заменяемым, производному классу разрешается (не требуется) добавлять (не удалять) общедоступные методы, и для каждого открытого метода, унаследованного от базового класса, производному классу разрешено (не требуется) ослаблять предварительные условия и/или усилить постусловия (а не наоборот). Кроме того, производный класс может иметь совершенно другие конструкторы, статические методы и закрытые методы.
Вернемся к Ellipse
и Circle
: если вы определяете термин Ellipse
для обозначения чего-то, что может быть изменено асимметрично (например, его методы позволяют изменять ширину и высоту независимо и гарантируют, что ширина и высота фактически изменятся до указанных значений), тогда это окончательное и точное определение термина Ellipse
. Если вы определяете объект под названием «Circle
» как нечто, размер которого нельзя изменять асимметрично, то это также ваша прерогатива, и это окончательное и точное определение термина «Circle
». Если вы определили эти термины таким образом, то, очевидно, то, что вы назвали Circle
, нельзя заменить на то, что вы назвали Ellipse
, поэтому наследование было бы неправильным. Что и требовалось доказать.
Так что ответ всегда: «это зависит от обстоятельств». В частности, это зависит от поведения базового и производного классов. Это не зависит от имени базового и производного классов, поскольку это произвольные метки. (Я не защищаю неаккуратные имена; однако я говорю, что вы не должны использовать интуитивную коннотацию имени, чтобы предположить, что вы знаете, что делает класс. Класс делает то, что он делает, а не то, что вы думаете, что он должен делать исходя из его имени.)
(Некоторых) людей беспокоит то, что вещь, которую вы назвали Circle
, возможно, не заменяет вещь, которую вы назвали Ellipse
, и этим людям я могу сказать только две вещи: (а) смиритесь с этим, и (б) переименуйте метки классов, если будете чувствовать себя после этого комфортнее. Например, переименуйте Ellipse
в ThingThatCanBeResizedAssymetrically
(штука, размер которой может быть изменен асимметрично) и Circle
в ThingThatCannotBeResizedAssymetrically
(штука, размер которой не может быть изменен асимметрично).
К сожалению, я искренне верю, что люди, которые чувствуют себя лучше после переименования вещей, не понимают сути. Дело в следующем: в объектно-ориентированном программировании объект определяется тем, как он себя ведет, а не меткой, используемой для его имени. Очевидно, что важно выбирать хорошие имена, но даже в этом случае выбранное имя не определяет объект. Определение объекта указывается общедоступными методами, включая контракты (предусловия и постусловия) этих методов. Наследование является правильным или неправильным в зависимости от поведения классов, а не их имен.
Если SortedList
имеет точно такой же открытый интерфейс, что и List
, является ли SortedList
разновидностью List
?
Возможно, нет.
Самое важное понимание состоит в том, что ответ зависит от подробностей контракта базового класса. Недостаточно знать, что общедоступные интерфейсы / сигнатуры методов совместимы; нужно также знать, совместимы ли контракты / поведение.
Важной частью предыдущего предложения являются слова «контракты / поведение». Эта фраза выходит далеко за рамки общедоступного интерфейса = сигнатур методов = имен методов, типов параметров и констант. Контракт метода означает его объявленное поведение = объявленные требования и обещания = объявленные предварительные условия и постусловия. Итак, если базовый класс имеет метод void insert(const Foo& x)
, контракт этого метода включает сигнатуру (то есть имя insert
и параметр const Foo&
), но выходит далеко за рамки этого, включая объявленные предварительные условия и постусловия метода.
Другое важное слово «объявленное». Здесь цель состоит в том, чтобы различать код внутри метода (при условии, что метод базового класса имеет код, т.е. при условии, что это не чисто виртуальная функция без реализации) и обещаниями, сделанными вне метода. Здесь всё усложняется. Предположим, List::insert(const Foo& x)
вставляет копию x
в конец данного List
, а переопределение этого метода в SortedList
вставляет x
в правильном порядке сортировки. Несмотря на то, что переопределение ведет себя несовместимо с кодом базового класса, наследование может быть правильным, если базовый класс делает «ослабление» или «адаптируемое» обещание. Например, если объявленное обещание List::insert (const Foo& x)
является чем-то расплывчатым, например: «обещает, что копия x
будет вставлена где-то в этом списке», то наследование, вероятно, в порядке, поскольку переопределение соответствует объявленному поведению, даже если оно несовместимо с реализованным поведением.
Производный класс должен делать то, что обещает базовый класс, а не то, что тот делает на самом деле.
Ключевой момент в том, что мы отделили объявленное поведение («спецификацию») от реализованного поведения («реализации») и полагаемся на спецификацию, а не на реализацию. Это очень важно, потому что в большом проценте случаев метод базового класса представляет собой чисто виртуальную функцию без реализации – единственное, на что можно положиться, это спецификация – просто нет реализации, на которую можно было бы положиться.
Вернемся к SortedList
и List
: похоже, что у List
есть один или несколько методов, которые имеют контракты, гарантирующие порядок, и поэтому SortedList
, вероятно, не является разновидностью List
. Например, если у List
есть метод, который позволяет вам переупорядочивать объекты, добавлять объекты в начало списка, добавлять объекты в конец списка или изменять i-ый элемент, и если эти методы дают типовое объявленное обещание, то SortedList
должен будет нарушить это объявленное поведение, и наследование будет некорректным. Но всё зависит от того, что объявляет базовый класс, – от контракта базового класса.