C++ получил в наследство от C приведение вида (тип)(что привести) – обычно называется приведением в стиле C. В C++ есть еще четыре явных приведения – static_cast, reinterpret_cast, dynamic_cast, const_cast.
C++ – не самый новый язык, и жаркие споры о том, что лучше – приведение в стиле C или использование *_cast в нужном сочетании, начались давно и не утихают по сей день. Не будем подливать масла в огонь, лучше рассмотрим пример, и пусть каждый сам решит, что ему нравится больше.
Здесь будут упомянуты конструкции, специфичные для Windows и технологии COM, но такие же проблемы могут возникать в любых достаточно сложных иерархиях классов, если не уделять достаточно внимания приведению типов.
Пример по мотивам реального кода из реального проекта с открытым кодом. В некоторой подсистеме проекта объявлен класс, реализующий несколько COM-интерфейсов:
Само собой, в реальной жизни интерфейсы имеют более осмысленные имена, но когда их ближе к десятку, это не очень помогает от проблемы, которая рассмотрена далее.
Напомним, каждый COM-интерфейс прямо или опосредованно наследуется от IUnknown, а IUnknown содержит метод QueryInterface(), правильная реализация которого настолько непроста, что Raymond Chen написал об этом сериал (тут, тут и тут).
Наш пример – как раз реализация QueryInterface() в классе выше. Краткая предыстория: когда разработчик объявляет новый COM-интерфейс, он обязан назначить ему уникальный идентификатор. Вызывающая сторона вызывает QueryInterface() для того, чтобы узнать, реализует ли объект интерфейс с таким идентификатором и, если реализует, получить указатель соответствующего типа. Конструкция «__uuidof()» просит Visual C++ во время компиляции найти и подставить идентификатор интерфейса, указанного в скобках.
Итак…
Реализация выше работает и почти совершенна. Она проверяет указатель перед разыменованием. Она проверяет, известный ли интерфейс у нее запросили. Она записывает нулевой указатель перед возвратом кода E_NOINTERFACE. Она увеличивает счетчик ссылок, если интерфейс поддержан. Она даже на запрос IUnknown правильно реагирует. Raymond Chen был бы доволен, если бы не один вопрос.
Зачем там приведения? Почему не написать «*ppv = this;»?
При множественном наследовании объект будет «сложен» из подобъектов базовых классов так, чтобы можно было получить доступ к каждому подобъекту отдельно. Скажем, какая-то функция умеет работать только c IComInterface2* – нужно передать ей указатель именно на этот подобъект, а не на производный объект, о составе которого она, вполне возможно, ничего не знает.
Присваивание «*ppv = this;» привело бы к тому, что всякий раз передавался бы адрес начала производного объекта, а не подобъектов, из которых он состоит. Попытка вызвать виртуальный метод интерфейса через указатель на другой подобъект, очевидно, приведет к долгой отладке.
Приведение в примере выше как раз обеспечивает корректировку указателя. Оно там необходимо, чтобы вызывающая сторона получала указатель именно на нужный подобъект.
Счастье есть? До этого абзаца – точно. Теперь проходит 100500 дней, проект развивается, в него добавляется новая функциональность. В следующем абзаце мы увидим последствия неудачного применения копипаста при попытке развить проект. Только давайте обойдемся без возражений, что «правильные программисты» при «правильном программировании» и «правильной архитектуре» так якобы не делают.
В другой подсистеме того же проекта с открытым кодом есть другой класс, реализующий тот же набор интерфейсов:
и, естественно, писать ту цепь условий заново никому не хочется, тем более что реализация, очевидно, такая же:
А теперь мысленно проиграем, что произойдет при запросе интерфейса IComInterface2. Управление пойдет по цепи if-else-if до совпадения идентификатора, и затем будет выполнено приведение в стиле C.
Параграф 5.3.5/5 стандарта C++ ISO/IEC 14882:2003(E) говорит, что при приведении в стиле C будет выполнен (в нашем случае) либо static_cast, либо, если static_cast невозможен, – reinterpret_cast.
В первом примере класс был унаследован от IComInterface2 и выполнялся static_cast указателя this к указателю на нужный подобъект.
Во втором примере класс уже не унаследован от IComInterface2 (да, копипаст плюс доработка напильником), поэтому static_cast невозможен. Будет выполнен reinterpret_cast, указатель this будет скопирован без изменений. И кстати, объект вообще не реализует IComInterface2. Здесь уместно слово ВДРУГ.
Вызывающая сторона при запросе IComInterface2 во втором примере получит ненулевой указатель на объект, который этот интерфейс не реализует и вообще никак к этому интерфейсу не относится.
Для сравнения, если использовать static_cast в каждой из веток if-else-if, компилятор выдаст сообщение об ошибке и второй пример не скомпилируется, это мягко намекнет разработчику, что надо поработать напильником еще немного. Минус день отладки, можно заняться чем-нибудь полезным.
Раз мы уже здесь, другая неудачная идея – использовать dynamic_cast. При использовании dynamic_cast во втором примере вызывающая сторона получит нулевой указатель и ложный код успешного выполнения метода, а у объекта будет зря вызвано увеличение счетчика ссылок, в результате он может утечь. Плюс пара часов отладки, но нулевой указатель хотя бы легче заметить, правда, смысла использовать dynamic_cast здесь вообще нет.
Можно предположить, что приведения в стиле C позволяют писать код короче, но усложняют написание правильного кода и только отодвигают момент, когда придется все же освоиться с приведениями *_cast.
Выводы очевидны. Используйте приведения в стиле C как можно чаще – так вы дадите другим разработчикам конкурентное преимущество, а сами (кто знает) можете однажды даже получить премию Дарвина.
Дмитрий Мещеряков
Департамент продуктов для ввода данных
C++ – не самый новый язык, и жаркие споры о том, что лучше – приведение в стиле C или использование *_cast в нужном сочетании, начались давно и не утихают по сей день. Не будем подливать масла в огонь, лучше рассмотрим пример, и пусть каждый сам решит, что ему нравится больше.
Здесь будут упомянуты конструкции, специфичные для Windows и технологии COM, но такие же проблемы могут возникать в любых достаточно сложных иерархиях классов, если не уделять достаточно внимания приведению типов.
Пример по мотивам реального кода из реального проекта с открытым кодом. В некоторой подсистеме проекта объявлен класс, реализующий несколько COM-интерфейсов:
class CInterfacesImplementor : public IComInterface1, public IComInterface2,
public IComInterface3, ...(еще интерфейсы с 4 по 9), public IComInterface10
{
// определения методов всякие
};
Само собой, в реальной жизни интерфейсы имеют более осмысленные имена, но когда их ближе к десятку, это не очень помогает от проблемы, которая рассмотрена далее.
Напомним, каждый COM-интерфейс прямо или опосредованно наследуется от IUnknown, а IUnknown содержит метод QueryInterface(), правильная реализация которого настолько непроста, что Raymond Chen написал об этом сериал (тут, тут и тут).
Наш пример – как раз реализация QueryInterface() в классе выше. Краткая предыстория: когда разработчик объявляет новый COM-интерфейс, он обязан назначить ему уникальный идентификатор. Вызывающая сторона вызывает QueryInterface() для того, чтобы узнать, реализует ли объект интерфейс с таким идентификатором и, если реализует, получить указатель соответствующего типа. Конструкция «__uuidof()» просит Visual C++ во время компиляции найти и подставить идентификатор интерфейса, указанного в скобках.
Итак…
HRESULT STDMETHODCALLTYPE CInterfaceImplementor::QueryInterface( REFIID iid, void** ppv )
{
if( ppv == 0 ) {
return E_POINTER;
}
if( iid == __uuidof( IUnknown ) || iid == __uuidof( IComInterface1 ) ) {
*ppv = (IComInterface1*)this;
} else if( iid == __uuidof( IComInterface2 ) ) {
*ppv = (IComInterface2*)this;
} else if( iid == __uuidof( IComInterface3 ) ) {
*ppv = (IComInterface3*)this;
} else if...
... // то же самое для каждого реализованного COM-интерфейса
} else { // никакие другие COM-интерфейсы этот класс не реализует
*ppv = 0;
return E_NOINTERFACE;
}
AddRef();
return S_OK;
}
Реализация выше работает и почти совершенна. Она проверяет указатель перед разыменованием. Она проверяет, известный ли интерфейс у нее запросили. Она записывает нулевой указатель перед возвратом кода E_NOINTERFACE. Она увеличивает счетчик ссылок, если интерфейс поддержан. Она даже на запрос IUnknown правильно реагирует. Raymond Chen был бы доволен, если бы не один вопрос.
Зачем там приведения? Почему не написать «*ppv = this;»?
При множественном наследовании объект будет «сложен» из подобъектов базовых классов так, чтобы можно было получить доступ к каждому подобъекту отдельно. Скажем, какая-то функция умеет работать только c IComInterface2* – нужно передать ей указатель именно на этот подобъект, а не на производный объект, о составе которого она, вполне возможно, ничего не знает.
Присваивание «*ppv = this;» привело бы к тому, что всякий раз передавался бы адрес начала производного объекта, а не подобъектов, из которых он состоит. Попытка вызвать виртуальный метод интерфейса через указатель на другой подобъект, очевидно, приведет к долгой отладке.
Приведение в примере выше как раз обеспечивает корректировку указателя. Оно там необходимо, чтобы вызывающая сторона получала указатель именно на нужный подобъект.
Счастье есть? До этого абзаца – точно. Теперь проходит 100500 дней, проект развивается, в него добавляется новая функциональность. В следующем абзаце мы увидим последствия неудачного применения копипаста при попытке развить проект. Только давайте обойдемся без возражений, что «правильные программисты» при «правильном программировании» и «правильной архитектуре» так якобы не делают.
В другой подсистеме того же проекта с открытым кодом есть другой класс, реализующий тот же набор интерфейсов:
class CYetOtherImplementor : public IComInterface1,
public IComInterface3, ...(еще интерфейсы с 4 по 9), public IComInterface10
{
// определения методов всякие
};
и, естественно, писать ту цепь условий заново никому не хочется, тем более что реализация, очевидно, такая же:
HRESULT STDMETHODCALLTYPE CYetOtherImplementor::QueryInterface( REFIID iid, void** ppv )
{
if( ppv == 0 ) {
return E_POINTER;
}
if( iid == __uuidof( IUnknown ) || iid == __uuidof( IComInterface1 ) ) {
*ppv = (IComInterface1*)this;
} else if( iid == __uuidof( IComInterface2 ) ) {
*ppv = (IComInterface2*)this;
} else if( iid == __uuidof( IComInterface3 ) ) {
*ppv = (IComInterface3*)this;
} else if...
... // то же самое для каждого реализованного COM-интерфейса
} else { // никакие другие COM-интерфейсы этот класс не реализует
*ppv = 0;
return E_NOINTERFACE;
}
// V2UncmUgaGlyaW5nIC0gd3d3LmFiYnl5LnJ1L3ZhY2FuY3k=
AddRef();
return S_OK;
}
А теперь мысленно проиграем, что произойдет при запросе интерфейса IComInterface2. Управление пойдет по цепи if-else-if до совпадения идентификатора, и затем будет выполнено приведение в стиле C.
Параграф 5.3.5/5 стандарта C++ ISO/IEC 14882:2003(E) говорит, что при приведении в стиле C будет выполнен (в нашем случае) либо static_cast, либо, если static_cast невозможен, – reinterpret_cast.
В первом примере класс был унаследован от IComInterface2 и выполнялся static_cast указателя this к указателю на нужный подобъект.
Во втором примере класс уже не унаследован от IComInterface2 (да, копипаст плюс доработка напильником), поэтому static_cast невозможен. Будет выполнен reinterpret_cast, указатель this будет скопирован без изменений. И кстати, объект вообще не реализует IComInterface2. Здесь уместно слово ВДРУГ.
Вызывающая сторона при запросе IComInterface2 во втором примере получит ненулевой указатель на объект, который этот интерфейс не реализует и вообще никак к этому интерфейсу не относится.
Для сравнения, если использовать static_cast в каждой из веток if-else-if, компилятор выдаст сообщение об ошибке и второй пример не скомпилируется, это мягко намекнет разработчику, что надо поработать напильником еще немного. Минус день отладки, можно заняться чем-нибудь полезным.
Раз мы уже здесь, другая неудачная идея – использовать dynamic_cast. При использовании dynamic_cast во втором примере вызывающая сторона получит нулевой указатель и ложный код успешного выполнения метода, а у объекта будет зря вызвано увеличение счетчика ссылок, в результате он может утечь. Плюс пара часов отладки, но нулевой указатель хотя бы легче заметить, правда, смысла использовать dynamic_cast здесь вообще нет.
Можно предположить, что приведения в стиле C позволяют писать код короче, но усложняют написание правильного кода и только отодвигают момент, когда придется все же освоиться с приведениями *_cast.
Выводы очевидны. Используйте приведения в стиле C как можно чаще – так вы дадите другим разработчикам конкурентное преимущество, а сами (кто знает) можете однажды даже получить премию Дарвина.
Дмитрий Мещеряков
Департамент продуктов для ввода данных