Как заголовочный файл С++ включает реализацию?
Ладно, ни в коем случае не эксперт C/C++, но я подумал, что смысл заголовочного файла - объявить функции, а файл C/CPP - определить реализацию.
Однако сегодня вечером, просматривая некоторый код C++, я нашел это в заголовочном файле класса...
public:
UInt32 GetNumberChannels() const { return _numberChannels; } // <-- Huh??
private:
UInt32 _numberChannels;
Так почему же в заголовке есть реализация? Связано ли это с ключевым словом const
? Это встроенный метод класса? В чем именно заключается преимущество/смысл такого подхода по сравнению с определением реализации в файле CPP?
Ответы
Ответ 1
Ладно, ни в коем случае не эксперт C/C++, но я подумал, что смысл заголовочного файла - объявить функции, а файл C/CPP - определить реализацию.
Истинная цель заголовочного файла состоит в том, чтобы делиться кодом между несколькими исходными файлами. Он обычно используется для ветки объявлений от реализаций для лучшего управления кодом, но это не является обязательным требованием. Можно написать код, который не зависит от заголовочных файлов, и можно написать код, который состоит только из заголовочных файлов (хорошие примеры тому - библиотеки STL и Boost). Помните, что когда препроцессор preprocessor встречает оператор #include
, он заменяет оператор содержимым ссылочного файла, тогда компилятор видит только законченный предварительно обработанный код.
Так, например, если у вас есть следующие файлы:
Foo.h:
#ifndef FooH
#define FooH
class Foo
{
public:
UInt32 GetNumberChannels() const;
private:
UInt32 _numberChannels;
};
#endif
foo.cpp:
#include "Foo.h"
UInt32 Foo::GetNumberChannels() const
{
return _numberChannels;
}
Bar.cpp:
#include "Foo.h"
Foo f;
UInt32 chans = f.GetNumberChannels();
Препроцессор preprocessor анализирует Foo.cpp и Bar.cpp по отдельности и создает следующий код, который затем компилятор анализирует:
Foo.cpp:
class Foo
{
public:
UInt32 GetNumberChannels() const;
private:
UInt32 _numberChannels;
};
UInt32 Foo::GetNumberChannels() const
{
return _numberChannels;
}
Bar.cpp:
class Foo
{
public:
UInt32 GetNumberChannels() const;
private:
UInt32 _numberChannels;
};
Foo f;
UInt32 chans = f.GetNumberChannels();
Bar.cpp компилируется в Bar.obj и содержит ссылку для вызова в Foo::GetNumberChannels()
. Foo.cpp компилируется в Foo.obj и содержит фактическую реализацию Foo::GetNumberChannels()
. После компиляции компоновщик затем сопоставляет файлы .obj и связывает их вместе для получения окончательного исполняемого файла.
Так почему же в заголовке есть реализация?
Включая реализацию метода в объявление метода, он неявно объявляется как встроенный (есть фактическое ключевое слово inline
, которое также может быть явно использовано). Указание на то, что компилятор должен встроить функцию, является лишь подсказкой, которая не гарантирует, что функция действительно будет встроена. Но если это так, то, откуда бы ни была вызвана встроенная функция, содержимое функции копируется непосредственно в сайт вызова, вместо генерации оператора CALL
для перехода в функцию и возврата к вызывающей стороне при выходе. Затем компилятор может учитывать окружающий код и оптимизировать скопированный код, если это возможно.
Связано ли это с ключевым словом const?
Нет. Ключевое слово const
просто указывает компилятору, что метод не изменит состояние объекта, к которому он вызывается во время выполнения.
В чем именно заключается преимущество/смысл такого подхода по сравнению с определением реализации в файле CPP?
При эффективном использовании он позволяет компилятору быстрее создавать и оптимизировать машинный код.
Ответ 2
Совершенно верно иметь реализацию функции в файле заголовка. Единственная проблема с этим - это нарушение правила единого определения. То есть, если вы включаете заголовок из нескольких других файлов, вы получите ошибку компилятора.
Однако есть одно исключение. Если вы объявляете функцию встроенной, она освобождается от правила с одним определением. Это то, что происходит здесь, поскольку функции-члены, определенные внутри определения класса, неявно встроены.
Inline сам по себе является подсказкой для компилятора, что функция может быть хорошим кандидатом для встраивания. То есть, расширение любого вызова к ней в определение функции, а не простой вызов функции. Это оптимизация, которая обрабатывает размер сгенерированного файла для более быстрого кода. В современных компиляторах, при условии, что эта подсказка для вложения для функции в основном игнорируется, за исключением эффектов, которые она имеет в правиле с одним определением. Кроме того, компилятор всегда может встроить любую функцию, которую он считает подходящей, даже если он не был объявлен inline
(явно или неявно).
В вашем примере использование const
после списка аргументов сигнализирует, что функция-член не изменяет объект, на который он вызывается. На практике это означает, что объект, на который указывает this
, и по расширению всех членов класса, будет считаться const
. То есть, попытка изменить их приведет к ошибке времени компиляции.
Ответ 3
Он объявлен неявно inline
благодаря функции-члену, определенной в объявлении класса. Это не означает, что компилятор должен встроить его, но это означает, что вы не нарушите одно правило определения. Он полностью не связан с const
*. Он также не связан с длиной и сложностью функции.
Если бы это была нечлена-функция, вам нужно было бы явно объявить ее как inline
:
inline void foo() { std::cout << "foo!\n"; }
* См. здесь для получения дополнительной информации о const
в конце функции-члена.
Ответ 4
Даже в простой C, можно поместить код в файл заголовка. Если вы это сделаете, вам обычно нужно объявить его static
, иначе несколько файлов .c, включая один и тот же заголовок, вызовут ошибку с множественной определенностью.
Препроцессор в тексте включает включенный файл, поэтому код в включенном файле становится частью исходного файла (по крайней мере, с точки зрения компилятора).
Разработчики С++ хотели включить объектно-ориентированное программирование с хорошим скрытием данных, поэтому они ожидали увидеть множество функций getter и setter. Они не хотели иметь необоснованного штрафа за исполнение. Таким образом, они разработали С++, чтобы геттеры и сеттеры могли быть объявлены не только в заголовке, но фактически реализованы, поэтому они были бы встроены. Эта функция, которую вы показали, является getter, и когда этот код С++ скомпилирован, вызова функции не будет; код для извлечения этого значения будет просто скомпилирован на месте.
Можно создать язык компьютера, который не имеет разметки файла заголовка/исходного файла, но имеет только фактические "модули", которые понимает компилятор. (С++ этого не делал, они просто строились поверх успешной модели исходных файлов C и текстовых включенных файлов заголовков.) Если исходные файлы являются модулями, компилятор мог бы вывести код из модуля, а затем встроить этот код. Но способ С++ упростил его реализацию.
Ответ 5
Насколько мне известно, существуют два типа методов, которые можно безопасно реализовать внутри файла заголовка.
- Встроенные методы - их реализация копируется в места, где они используются, поэтому нет проблем с ошибками компоновщика с двойным определением;
- Методы шаблонов - они фактически скомпилированы в момент создания шаблона (например, когда кто-то вводит тип вместо шаблона), поэтому снова нет возможности проблемы с двойным определением.
Я полагаю, ваш пример подходит для первого случая.
Ответ 6
Сохранение реализации в файле заголовка класса работает, так как я уверен, что вы знаете, если вы скомпилировали свой код. Ключевое слово const
гарантирует, что вы не меняете каких-либо членов, он сохраняет экземпляр неизменный на время вызова метода.
Ответ 7
C++ стандартные цитаты
C++ 17 N4659 стандартный черновик 10.1.6
"Встроенный спецификатор" говорит, что методы неявно встроены:
4 Функция, определенная в определении класса, является встроенной функцией.
и далее далее мы видим, что встроенные методы не только могут, но и должны быть определены во всех единицах перевода:
6 Встроенная функция или переменная должна быть определена в каждой единице перевода, в которой она используется odr, и должна имеют одинаковое определение в каждом случае (6.2).
Это также явно упоминается в примечании к 12.2.1 "Функции-члены":
1 Функция-член может быть определена (11.4) в определении класса, и в этом случае она является встроенной функцией-членом (10.1.6) [...]
3 [Примечание: в программе может быть не более одного определения не встроенной функции-члена. Может быть более одного встроенного определения функции-члена в программе. См. 6.2 и 10.1.6. - конец примечания]
Внедрение GCC 8.3
main.cpp
struct MyClass {
void myMethod() {}
};
int main() {
MyClass().myMethod();
}
Скомпилируйте и просмотрите символы:
g++ -c main.cpp
nm -C main.o
выход:
U _GLOBAL_OFFSET_TABLE_
0000000000000000 W MyClass::myMethod()
U __stack_chk_fail
0000000000000000 T main
затем мы видим из man nm
, что символ MyClass::myMethod
помечен как слабый в объектных файлах ELF, что означает, что он может появляться в нескольких объектных файлах:
"W" "w" Символ - это слабый символ, который не был специально помечен как символ слабого объекта. Когда слабый определенный символ связан с нормальным определенным символом, нормальный определенный символ используется без ошибок. Когда слабый неопределенный символ связан и символ не определен, значение символа определяется системным образом без ошибок. В некоторых системах верхний регистр указывает, что задано значение по умолчанию.