Почему переопределенная функция в производном классе скрывает другие перегрузки базового класса?


220

Рассмотрим код:

#include <stdio.h>

class Base {
public: 
    virtual void gogo(int a){
        printf(" Base :: gogo (int) \n");
    };

    virtual void gogo(int* a){
        printf(" Base :: gogo (int*) \n");
    };
};

class Derived : public Base{
public:
    virtual void gogo(int* a){
        printf(" Derived :: gogo (int*) \n");
    };
};

int main(){
    Derived obj;
    obj.gogo(7);
}

Получил эту ошибку:

> g ++ -pedantic -Os test.cpp -o test
test.cpp: в функции `int main () ':
test.cpp: 31: ошибка: нет соответствующей функции для вызова Derived :: gogo (int) '
test.cpp: 21: примечание: кандидаты: виртуальные пустые Derived :: gogo (int *) 
test.cpp: 33: 2: предупреждение: нет новой строки в конце файла
> Код выхода: 1

Здесь функция производного класса затмевает все функции с одинаковыми именами (но не сигнатурой) в базовом классе. Так или иначе, это поведение C ++ не выглядит хорошо. Не полиморфный.



8
блестящий вопрос, я только недавно обнаружил это
Мэтт Джойнер

11
Я думаю, что Бьярне (из ссылки, опубликованной Mac) лучше всего выразить это одним предложением: «В C ++ нет перегрузки между областями действия - области производных классов не являются исключением из этого общего правила».
Сивабуд

7
@Ashish Эта ссылка не работает. Вот правильный (на данный момент) - stroustrup.com/bs_faq2.html#overloadderived
nsane

3
Также хотелось бы отметить, что obj.Base::gogo(7);все еще работает, вызывая скрытую функцию.
форумчан

Ответы:


406

Судя по формулировке вашего вопроса (вы использовали слово «скрыть»), вы уже знаете, что здесь происходит. Это явление называется «сокрытие имени». По какой-то причине каждый раз, когда кто-то задает вопрос о том, почему происходит скрытие имени, люди, которые отвечают, либо говорят, что это называется «скрытием имени», и объясняют, как это работает (что вы, вероятно, уже знаете), либо объясняют, как это переопределить (что вы никогда не задавался вопросом), но, кажется, никому нет дела до вопроса «почему».

Решение, обоснование сокрытия имени, т. Е. Почему оно на самом деле было разработано для C ++, состоит в том, чтобы избежать определенного противоречивого, непредвиденного и потенциально опасного поведения, которое может иметь место, если унаследованный набор перегруженных функций может смешиваться с текущим набором перегрузки в данном классе. Вы, вероятно, знаете, что в C ++ разрешение перегрузки работает, выбирая лучшую функцию из набора кандидатов. Это делается путем сопоставления типов аргументов с типами параметров. Правила соответствия иногда могут быть сложными и часто приводить к результатам, которые могут быть восприняты неподготовленным пользователем как нелогичные. Добавление новых функций к набору ранее существующих может привести к довольно резкому изменению результатов разрешения перегрузки.

Например, допустим, что в базовом классе Bесть функция-член, fooкоторая принимает параметр типа void *, и все вызовы foo(NULL)разрешаются B::foo(void *). Допустим, имя не скрыто, и это B::foo(void *)видно во многих различных классах, происходящих из B. Однако, скажем, в некотором [косвенном, удаленном] потомке Dкласса Bопределена функция foo(int). Теперь без скрытия имени Dесть foo(void *)и foo(int)видимый, и участвующий в разрешении перегрузки. К какой функции будут обращаться вызовы foo(NULL), если она выполняется через объект типа D? Они будут разрешены D::foo(int), так intкак это лучшее соответствие для целого нуля (т.е.NULL ), чем любой тип указателя. Таким образом, по всей иерархии призываетfoo(NULL) разрешается одной функции, тогда как в D(и под) они внезапно разрешаются к другой.

Другой пример приведен в «Проектировании и развитии C ++» , стр. 77:

class Base {
    int x;
public:
    virtual void copy(Base* p) { x = p-> x; }
};

class Derived{
    int xx;
public:
    virtual void copy(Derived* p) { xx = p->xx; Base::copy(p); }
};

void f(Base a, Derived b)
{
    a.copy(&b); // ok: copy Base part of b
    b.copy(&a); // error: copy(Base*) is hidden by copy(Derived*)
}

Без этого правила состояние b будет частично обновлено, что приведет к нарезке.

Такое поведение было сочтено нежелательным, когда язык был разработан. В качестве лучшего подхода было решено следовать спецификации «скрытия имен», что означает, что каждый класс начинается с «чистого листа» относительно каждого имени метода, которое он объявляет. Чтобы переопределить это поведение, от пользователя требуется явное действие: первоначально переопределение унаследованного метода (ов) (в настоящее время не рекомендуется), теперь явное использование using-декларации.

Как вы правильно заметили в своем первоначальном посте (я имею в виду замечание «Не полиморфно»), такое поведение может рассматриваться как нарушение отношения IS-A между классами. Это правда, но, видимо, тогда было решено, что в конце концов сокрытие имени окажется меньшим злом.


22
Да, это реальный ответ на вопрос. Спасибо. Мне тоже было любопытно.
Всезнающий

4
Отличный ответ! Кроме того, с практической точки зрения компиляция, вероятно, будет намного медленнее, если поиск по имени должен будет каждый раз идти до самого верха.
Дрю Хол

6
(Старый ответ, я знаю.) Теперь nullptrя возразил бы против вашего примера, сказав: «Если вы хотите вызвать void*версию, вы должны использовать тип указателя». Есть ли другой пример, где это может быть плохо?
GManNickG

3
Сокрытие имени на самом деле не зло. Отношение "is-a" все еще существует и доступно через базовый интерфейс. Так что, возможно d->foo(), не получит "Is-a Base", но static_cast<Base*>(d)->foo() получит , включая динамическую отправку.
Kerrek SB

12
Этот ответ бесполезен, поскольку приведенный пример ведет себя одинаково со скрытием или без него: D :: foo (int) будет вызываться либо потому, что он лучше соответствует, либо потому, что он скрыл B: foo (int).
Ричард Вольф

46

Правила разрешения имен говорят, что поиск имени останавливается в первой области, в которой найдено подходящее имя. В этот момент вступают в силу правила разрешения перегрузки, чтобы найти наилучшее совпадение доступных функций.

В таком случае, gogo(int*) находится (один) в области видимости класса Derived, и поскольку стандартного преобразования из int в int * не происходит, поиск завершается неудачно.

Решение состоит в том, чтобы ввести объявления Base через объявление using в классе Derived:

using Base::gogo;

... позволил бы правилам поиска имени найти всех кандидатов, и, таким образом, разрешение перегрузки продолжалось бы так, как вы ожидали.


10
ОП: «Почему переопределенная функция в производном классе скрывает другие перегрузки базового класса?» Этот ответ: «Потому что это делает».
Ричард Вольф

12

Это «По замыслу». В C ++ разрешение перегрузки для этого типа метода работает следующим образом.

  • Начиная с типа ссылки, а затем перейдя к базовому типу, найдите первый тип, у которого есть метод с именем "gogo".
  • Принимая во внимание только методы с именем "gogo" для этого типа, найти соответствующую перегрузку

Поскольку Derived не имеет соответствующей функции с именем "gogo", разрешение перегрузки завершается неудачно.


2

Сокрытие имени имеет смысл, потому что оно предотвращает неоднозначности в разрешении имен.

Рассмотрим этот код:

class Base
{
public:
    void func (float x) { ... }
}

class Derived: public Base
{
public:
    void func (double x) { ... }
}

Derived dobj;

Если бы Base::func(float)не было скрыто Derived::func(double)в Derived, мы бы вызывали функцию базового класса при вызове dobj.func(0.f), даже если число с плавающей запятой можно повысить до двойного.

Ссылка: http://bastian.rieck.ru/blog/posts/2016/name_hiding_cxx/

Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.