Процесс компиляции программ


Зачем нам нужно это изучать?

  • У студентов часто возникают с этим проблемы — когда компилятор пишет ошибку, а человек не понимает, что ему говорят.
  • Если вы делаете ошибку в организации программы, причём такую ошибку, которая сразу к проблеме не приводит, то бывает такое, что при компиляции чуть-чуть по-другому всё сломается. Причём даже в крупных компаниях такое случается.

Самое интересное, что ни в одной литературе про компиляцию не рассказывается (в совсем базовой считается, что это сложно, а в продвинутой — что вы всё знаете), а все, кто это знает, говорят, что пришло с опытом.

Базовые знания об этапах компиляции.

Обычно мы компилируем программу как g++ program.cpp. А вот чего мы пока не знаем, так это того, что g++ не делает всю работу самостоятельно, а вызывает другие команды, которые выполняют компиляцию по частям. И если посмотреть, что там, то происходит cc1plus, потом as, в конце collect2, который вызывает ld. Давайте попытаемся это повторить.

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

  1. Препроцессирование. Выполняется при помощи g++ -E (если дополнительно передать ключ -P, то вывод будет чуть короче), выходной файл обычно имеет расширение .i. На файл с расширением .i можно и глазами посмотреть — в нём будет куча текста вместо #include, а потом наш код. Собственно, #include — директива препроцессора, которая тупо вставляет указанный файл в то место, где написана. Также препроцессор занимается макросами (#define). О них позже.
  2. Трансляция. Выполняется при помощи g++ -S, выходной файл обычно имеет расширение .s. «Трансляция» — это (с английского) «перевод». Кого и куда переводим? Наш язык в ассемблер. Если передать параметр -masm=intel, можно уточнить, в какой именно ассемблер переводить (как было сказано в 01_asm, ассемблеры отличаются в зависимости от инструмента).
  3. Ассемблирование. Выполняется специальной утилитой as, выходной файл обычно имеет расширение .o (и называется объектным файлом). На данном этапе не происходит ничего интересного — просто инструкции, которые были в ассемблере, перегоняются в машинный код. Поэтому файлы .o бесполезно смотреть глазами, они бинарные, для этого есть специальные утилиты, например, objdump. Про него будет рассказано чуть позже.
  4. Линковка. Выполняется простым вызовом g++ от объектного файла. На выходе даёт исполняемый файл. Нужна, если файлов несколько: мы запускаем препроцессор, трансляцию и ассемблирование независимо для каждого файла, а объединяются они только на этапе линковки. Независимые .cpp файлы называют единицами трансляции. Разумеется, только в одной единице должен быть main. В этом main'е, кстати, можно не делать return 0, его туда вставит компилятор.
    Сто́ит сказать, что информация о линковке верна до появления модулей в C++20, где можно доставать данные одного файла для другого. Там появляется зависимость файлов друг от друга, а значит компилировать их надо в определённом порядке.

Классическая схема этапов компиляции выглядит так:

Compilation graph

Есть похожая статья на хабре по теме.

Объявление и определение.

Очень хочется слинковать вот это:

// a.cpp:
int main() {
	f();
}
// b.cpp:
#include <cstdio>

void f() {
	printf("Hello, world!\n");
}

Это не компилируется, а точнее ошибка происходит на этапе трансляции a.cpp. В тексте ошибки написано, что f не определена в области видимости. Всё потому, что для того, чтобы вызвать функцию, надо что-то про неё знать. Например, если мы передаём в функцию int — это один ассемблерный код, а если double — то совершенно другой (потому что разные calling convention'ы могут быть). Поэтому на этапе трансляции нужно знать сигнатуру функции. Чтобы указать эту сигнатуру, в C++ есть объявления:

// a.cpp:			
void f(); // Вот это объявление.

int main() {
	f();
}
// b.cpp:
#include <cstdio>

void f() {
	printf("Hello world");
}

Когда мы пишем функцию и точку с запятой — это объявление/декларация (declaration). Это значит, что где-то в программе такая функция есть. А когда мы пишем тело функции в фигурных скобках — это определение (definition).

Кстати, написать объявление бывает полезно даже если у нас один файл. Например, в таком файле:

#include <cstdio>

int main() {
	f();
}

void f() {
	printf("Hello, world\n");
}

Это не компилируется, и дело в том, что компилятор смотрит файл сверху вниз, и когда доходит до вызова функции f внутри main, он ещё не дошёл до её определения. Тут можно переставить функции местами, да, но если у нас есть взаиморекурсивные функции, то там переставить их не получится — только написать декларацию.

Ошибки линковки. Инструменты nm и objdump. Ключевое слово static.

Рассмотрим такой пример:

// a.cpp
#include <cstdio>

void f()
{
	printf("Hello, a.cpp!\n");
}
// b.cpp
#include <cstdio>

void f()
{
	printf("Hello, b.cpp!\n");
}
// main.cpp
void f();

int main()
{
	f();
}

Тут вам на этапе линковки напишут, что функция f() определяется дважды. Чтобы красиво посмотреть, как это работает, можно использовать утилиту nm. Когда вы сгенерируете a.o и вызовете nm -C a.o, то увидите что-то такое:

                 U puts
0000000000000000 T f()

Что делает ключ -C, оставим на потом. На то что тут находится puts вместо printf, тоже обращать внимание не надо, это просто такая оптимизация компилятора — когда можно заменить printf на puts, заменяем.
А обратить внимание надо на то, что puts не определена (об этом нам говорит буква U), а функция f() — определена в секции .text (буква T). У main.cpp, понятно, будет неопределённая функция f() и определённая main. Поэтому, имея эти объектные файлы, можно слинковать main.cpp и a.cpp. Или main.cpp и b.cpp. Без перекомпиляции. Но нельзя все три вместе, ведь f() будет определена дважды.

Если мы хотим посмотреть на объектные файлы поподробнее, нам понадобится утилита objdump. У неё есть бесчисленное много ключей, которые говорят, что мы хотим увидеть. Например, -x — выдать вообще всё. Нам сейчас нужно -d — дизассемблирование и -r — релокации. Когда мы вызовем objdump -dr -Mintel -C main.o, мы увидим, что на месте вызова функции f находится call и нули. Потому что неизвестно, где эта функция, надо на этапе линковки подставить её адрес. А чтобы узнать, что именно подставить, есть релокации, которые информацию об этом и содержат. В общем случае релокация — информация о том, какие изменения нужно сделать с программой, чтобы файл можно было запустить.

Давайте теперь вот на что посмотрим. Пусть в нашем файле определена функция f(). И где-то по случайному совпадению далеко-далеко также определена функция f(). Понятно, что оно так не слинкуется. Но мы можем иметь в виду, что наша функция f нужна только нам и никак наружу не торчит. Для этого имеется специальный модификатор: static. Если сделать на такие функции nm, то можно увидеть символ t вместо T, который как раз обозначает локальность для единицы трансляции. Вообще функции, локальные для одного файла сто́ит помечать как static в любом случае, потому что это ещё помогает компилятору сделать оптимизации.

Глобальные переменные.

Для глобальных переменных всё то же самое, что и для функций: например, мы также можем сослаться на глобальную переменную из другого файла. Только тут другой синтаксис:

extern int x; // Объявление.

int x;        // Определение.

И точно также в глобальных переменных можно писать static. А теперь пример:

// a.cpp
extern int a;

void f();

int main()
{
	f();
	a = 5;
	f();
}
// b.cpp
#include <cstdio>

int a;

void f()
{
	printf("%d\n", a);
}

В первый раз вам выведут 0, потому что глобальные переменные инициализируются нулями. Локальные переменные хранятся на стеке, и там какие данные были до захода в функцию, те там и будут. А глобальные выделяются один раз, и ОС даёт вам их проинициализированные нулём (иначе там могут быть чужие данные, их нельзя отдавать).

Декорирование имён. extern "C".

Обсуждённая нами модель компиляции позволяет использовать несколько разных языков программирования. Пока ЯП умеет транслироваться в объектные файлы, проблемы могут возникнуть только на этапе линковки. Например, никто не мешает вам взять уже готовый ассемблерник и скомпилировать его с .cpp файлом. Но в вызове ассемблера есть одна проблема. Тут надо поговорить о такой вещи как extern "C". В языке C всё было так: имя функции и имя символа для линковщика — это одно и то же. Если мы скомпилируем файл

// a.c <-- C, не C++.
void foo(int)
{
	// ...
}

То имя символа, которое мы увидим в nm будет foo. А в C++ появилась перегрузка функций, то есть void foo(int) и void foo(double) — это две разные функции, обе из которых можно вызывать. Поэтому одно имя символа присвоить им нельзя. Так что компилятор mangle'ит/декорирует имена, то есть изменяет их так, чтобы символы получились уникальными. nm даже может выдать вам эти имена (в данном случае получится _Z3fooi и _Z3food). Но у вас есть и возможность увидеть их по-человечески: для этого существует уже упомянутый ключ -C, который если передать программе nm, то она раздекорирует всё обратно и выдаст вам имена человекочитаемо. objdump'у этот ключ дать тоже можно. А ещё есть утилита c++filt, которая по имени символа даёт сигнатуру функции.

Так вот, extern "C" говорит, что при линковке нам не нужно проводить декорацию. И если у нас в ассемблерном файле написано fibonacci:, то вам и нужно оставить имя символа как есть:

extern "C" uint32_t fibonacci(uint32_t n);

У функций, имеющих разные сигнатуры, но помеченных как extern "C", после компиляции не будет информации о типах их аргументов, поэтому это слинкуется, но работать не будет (ну либо будет, но тут UB, так как, например, типы аргументов ожидаются разные).

Линковка со стандартной библиотекой.

Возьмём теперь объявление printf из cstdio и вставим его объявление вручную:

extern "C" int printf(const char*, ...);

int main() {
	printf("Hello, world!");
}

Такая программа тоже работает. А где определение printf, возникает вопрос? А вот смотрите. На этапе связывания связываются не только ваши файлы. Помимо этого в параметры связывания добавляются несколько ещё объектных файлов и несколько библиотек. В нашей модели мира хватит информации о том, что библиотека — просто набор объектных файлов. И вот при линковке вам дают стандартную библиотеку C++ (-lstdc++), математическую библиотеку (-lm), библиотеку -libgcc, чтобы если вы делаете арифметику в 128-битных числах, то компилятор мог вызвать функцию __udivti3 (деление), и кучу всего ещё. В нашем случае нужна одна — -lc, в которой и лежит printf. А ещё один из объектных файлов, с которыми вы линкуетесь, содержит функцию _start (это может быть файл crt1.o), которая вызывает main.

Headers (заголовочные файлы). Директива #include.

Если мы используем одну функцию во многих файлах, то нам надо писать её сигнатуру везде. А если мы её меняем, то вообще повеситься можно. Поэтому так не делают. А как делают? А так: декларация выделяется в отдельный файл. Этот файл имеет расширение .h и называется заголовочным. По сути это же происходит в стандартной библиотеке. Подключаются заголовочные файлы директивой #include <filename>, если они из какой-то библиотеки, или #include "filename", если они ваши. В чём разница? Стандартное объяснение — тем, что треугольные скобки сначала ищут в библиотеках, а потом в вашей программе, а кавычки — наоборот. На самом деле у обоих вариантов просто есть список путей, где искать файл, и эти списки разные.

Но с заголовками нужно правильно работать. Например, нельзя делать #include "a.cpp". Почему? Потому что все определённые в a.cpp функции и переменные просочатся туда, куда вы его подключили. И если файл у вас один, то ещё ничего, а если больше, то в каждом, где написано #include "a.cpp", будет определение, а значит определение одного и того же объекта будет написано несколько раз. Аналогичный эффект будет, если писать определение сразу в заголовочном файле, не надо так.

К сожалению, у директивы #include есть несколько нюансов.

Предотвращение повторного включения.

Давайте поговорим про структуры. Что будет, если мы в заголовочном файле создадим struct, и подключим этот файл? Да ничего. Абсолютно ничего. Сгенерированный ассемблерный код будет одинаковым. У структур нет определения по сути, потому что они не генерируют код. Поэтому их пишут в заголовках. При этом их методы можно (но не нужно) определять там же, потому что они воспринимаются компилятором как inline. А кто такой этот inline и как он работает — смотри дальше. Но со структурами есть один нюанс. Рассмотрим вот что:

// x.h:
struct x {};
// y.h:
#include "x.h"
// z.h:
#include "x.h"
// a.cpp:
#include "y.h" //    -->    `struct x{};`.
#include "z.h" //    -->    `struct x{};` ошибка компиляции, повторное определение.

Стандартный способ это поправить выглядит так:

// x.h:
#ifndef X_H // Если мы уже определили макрос, то заголовок целиком игнорируется.
#define X_H	// Если не игнорируется, то помечаем, что файл мы подключили.

struct x {};

#endif // В блок #ifndef...#endif заключается весь файл целиком.

Это называется include guard. Ещё все возможные компиляторы поддерживают #pragma once (эффект как у include guard, но проще). И на самом деле #pragma once работает лучше, потому что не опирается на имя файла, например. Но его нет в стандарте, что грустно.

Есть один нюанс с #pragma once'ом. Если у вас есть две жёстких ссылки на один файл, то у него проблемы. Если у вас include guard, то интуитивно понятно, что такое разные файлы — когда макросы у них разные. А вот считать ли разными файлами две жёстких ссылки на одно и то же — вопрос сложный. Другое дело, что делать так, чтобы источники содержали жёсткие или символические ссылки, уже довольно странно.

Forward-декларации.

// a.h
#ifndef A_H
#define A_H

#include "b.h" // Nothing, then `struct b { ... };`

struct a {
	b* bb;
};
#endif
// b.h
#ifndef B_H
#define B_H

#include "a.h" // Nothing, then `struct a { ... };`

struct b {
	a* aa;
};
#endif
// main.cpp
#include "a.h" // `struct b { ... }; struct a { ... };`
#include "b.h" // Nothing.

Понятно, в чём проблема заключается. Мы подключаем a.h, в нём — b.h, а в нём, поскольку мы уже зашли в a.h, include guard нам его блокирует. И мы сначала определяем структуру b, а потом — a. И при просмотре структуры b, мы не будем знать, что такое a.

Для этого есть конструкция, называемая forward-декларацией. Она выглядит так:

// a.h
#ifndef A_H
#define A_H

struct b;

struct a {
	b* bb;
};
#endif
// b.h
#ifndef B_H
#define B_H

struct a;

struct b {
	a* aa;
};
#endif

Чтобы завести указатель, нам не надо знать содержимое структуры. Поэтому мы просто говорим, что b — это некоторая структура, которую мы дальше определим.

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

  • Во-первых, из-за времени компиляции. Большое количество подключений в заголовочных файлах негативно влияет на него, потому что если меняется header, то необходимо перекомпилировать все файлы, которые подключают его (даже не непосредственно), что может быть долго.
  • Второй момент — когда у нас цикл из заголовочных файлов, это всегда ошибка, даже если там нет проблем как в примере, потому что результат компиляции зависит от того, что вы подключаете первым.

Пока структуру не определили, структура — это incomplete type. Например, на момент объявления struct b; в коде выше, b — incomplete. Кстати, в тот момент, когда вы находитесь в середине определения класса, он всё ещё incomplete. Все, что можно с incomplete типами делать — это объявлять функции с их использованием и создавать указатель. Incomplete тип становится полным типом после определения. Пока что информация об incomplete-типах нам ни к чему, но она выстрелит позже.

Правило единственного определения.

А теперь такой пример:

// a.cpp
#include <iostream>

struct x {
	int a;
	// padding
	double b;
	int c;
	int d;
};

x f();

int main() {
	x xx = f();
	std::cout << xx.a << " "
	          << xx.b << " "
	          << xx.c << " "
	          << xx.d << std::endl;
}
// b.cpp
struct x {
	int a;
	int b;
	int c;
	int d;
	int e;
};

x f() {
	x result;
	result.a = 1;
	result.b = 2;
	result.c = 3;
	result.d = 4;
	result.e = 5;
	return result;
};

Тут стоит вспомнить, что структуры при линковке не играют никакой роли, то есть линковщику всё равно, что у нас структура x определена в двух местах. Поэтому такая программа отлично скомпилируется и запустится, но тем не менее она является некорректной. По стандарту такая программа будет работать неизвестно как, а по жизни данные поедут. А именно 2 пропадёт из-за выравнивания double, 3 и 4 превратятся в одно число (double), а 5 будет на своём месте, а x::e из файла a.cpp будет просто не проинициализирован. Правило, согласно которому так нельзя, называется one-definition rule/правило единственного определения. Кстати, нарушением ODR является даже тасовка полей.

Inlining.

int foo(int a, int b) {
	return a + b;
}

int bar(int a, int b) {
	return foo(a, b) - a;
}

Если посмотреть на ассемблерный код для bar, то там не будет вызова функции foo, а будет return b;. Это называется inlining — когда мы берём тело одной функции и вставляем внутрь другой как оно есть. Это связано, например, со стилем программирования в текущем мире (много маленьких функций, которые делают маленькие вещи) — мы убираем все эти абстракции, сливаем функции в одну и потом оптимизируем что там есть.

Но есть один нюанс...

Модификатор inline.

// a.c
void say_hello();

int main() {
	say_hello();
}
// b.c
#include <cstdio>

void say_hello() {
	printf("Hello, world!\n");
}

Тут не произойдёт inlining, а почему? А потому что компилятор умеет подставлять тело функций только внутри одной единицы трансляции (так как inlining происходит на момент трансляции, а тогда у компилятора нет функций из других единиц).

Тут умеренно умалчивается, что модель компиляции, которую мы обсуждаем — древняя и бородатая. Мы можем передать ключ -flto в компилятор, тогда всё будет за'inline'ено. Дело в том, что при включенном режиме linking time optimization, мы откладываем на потом генерацию кода и генерируем его на этапе линковки. В таком случае линковка может занимать много времени, поэтому применяется при сборке с оптимизациями. Подробнее о режиме LTO — сильно позже.

Но тем не менее давайте рассмотрим, как без LTO исправить проблему с отсутствием inlining'а. Мы можем написать в заголовочном файле тело, это поможет, но это, как мы знаем, ошибка компиляции. Хм-м, ну, можно не только написать функцию в заголовочном файле, но и пометить её как static, но это даёт вам свою функцию на каждую единицу трансляции, что, во-первых, бывает просто не тем, что вы хотите, а во-вторых, кратно увеличивает размер выходного файла.

Поэтому есть модификатор inline. Он нужен для того, чтобы линковщик не дал ошибку нарушения ODR. Модификатор inline напрямую никак не влияет на то, что функции встраиваются. Если посмотреть на inline через nm, то там увидим W (weak) — из нескольких функций можно выбрать любую (предполагается, что все они одинаковые).

По сути inline — указание компилятору, что теперь за соблюдением ODR следите вы, а не он. И если ODR вы нарушаете, то это неопределённое поведение (ill-formed, no diagnostic required). ill-formed, no diagnostic required — это ситуация, когда программа некорректна, но никто не заставляет компилятор вам об этом говорить. Он может (у GCC есть такая возможность: если дать g++ ключи -flto -Wodr, он вам об этом скажет), но не обязан. А по жизни линковщик выберет произвольную из имеющихся функций (например, из первой единицы трансляции или вообще разные в разных местах):

// a.cpp
#include <cstdio>

inline void f() {
	printf("Hello, a.cpp!\n");
}

void g();

int main() {
	f();
	g();
}
// b.cpp
inline void f() {
	printf("Hello, b.cpp!\n");
}

void g() {
	f();
}

Если скомпилировать этот код с оптимизацией, обе функции f будут за'inline'ены, и всё будет хорошо. Если без, то зависит от порядка файлов: g++ a.cpp b.cpp может вполне выдавать Hello, a.cpp! два раза, а g++ b.cpp a.cppHello, b.cpp! два раза.

Если нужно именно за'inline'ить функцию, то есть нестандартизированные модификаторы типа __forceinline, однако даже они могут игнорироваться компилятором. Inlining функции может снизить производительность: на эту тему можно послушать доклад Антона Полухина на C++ Russia 2017.

Остальные команды препроцессора.

#include обсудили уже вдоль и поперёк. Ещё есть директивы #if, #ifdef, #ifndef, #else, #elif, #endif, которые дают условную компиляцию. То есть если выполнено какое-то условие, можно выполнить один код, а иначе — другой.

Определение макроса.

И ещё есть макросы: определить макрос (#define) и разопределить макрос (#undef):

#define PI 3.14159265
double circumference(double r) {
    return 2 * PI * r;
}

Текст, который идет после имени макроса, называется replacement. Replacement отделяется от имени макроса пробелом и распространяется до конца строки. Все вхождения идентификатора PI ниже этой директивы будут заменены на replacement. Самый простой макрос — object-like, его вы видите выше, чуть более сложный — function-like:

#define MIN(x, y) x < y ? x : y

printf("%d", MIN(4, 5));

Что нам нужно про это знать — макросы работают с токенами. Они не знают вообще ничего о том, что вы делаете. Вы можете написать

#include <cerrno>

int main() {
	int errno = 42;
}

И получить отрешённое от реальности сообщение об ошибке. А дело всё в том, что это на этапе препроцессинга раскрывается, например, так:

int main() {
	int (*__errno_location()) = 42;
}

И тут компилятор видит более отъявленный бред, нежели называние переменной так, как нельзя.

Что ещё не видит препроцессор, так это синтаксическую структуру и приоритет операций. Более страшные вещи получаются, когда пишется что-то такое:

#define MUL(x, y) x * y

int main() {
	int z = MUL(2, 1 + 1);
}

Потому что раскрывается это в

int main() {
	int z = 2 * 1 + 1;
}

Это не то, что вы хотите. Поэтому когда вы такое пишите, нужно, во-первых, все аргументы запихивать в скобки, во-вторых — само выражение тоже, а в-третьих, это вас никак не спасёт от чего-то такого:

#define max(a, b) ((a) < (b) ? (a) : (b))

int main() {
	int x = 1;
	int y = 2;
	int z = max(x++, ++y);
}

Поэтому перед написанием макросов три раза подумайте, нужно ли оно, а если нужно, будьте очень аккуратны. А ещё, если вы используете отладчик, то он ничего не знает про макросы, зачем ему знать. Поэтому в отладчике написать «вызов макроса» Вы обычно не можете. См. также FAQ Бьярна Страуструпа о том, почему макросы — это плохо.

Ещё #define позволяет переопределять макросы.

#define STR "abc"
const char* first = STR; // "abc".
#define STR "def"
const char* second = STR; // "def".

Replacement макроса не препроцессируется при определении макроса, но результат раскрытия макроса препроцессируется повторно:

#define Y foo
#define X Y   // Это не `#define X foo`.
#define Y bar // Это не `#define foo bar`.
X             // Раскрывается `X` -> `Y` -> `bar`.

Также по спецификации препроцессор никогда не должен раскрывать макрос изнутри самого себя, а оставлять вложенный идентификатор как есть:

#define M { M }
M   // Раскрывается в { M }.

Ещё пример:

#define A a{ B }
#define B b{ C }
#define C c{ A }
A // a{ b{ c{ A } } }
B // b{ c{ a{ B } } }
C // c{ a{ b{ C } } }

Условная компиляция. Проверка макроса.

Директивы #ifdef, #ifndef, #if, #else, #elif, #endif позволяют отпрепроцессировать часть файла, лишь при определенном условии. Директивы #ifdef, #ifndef проверяют определен ли указанный макрос. Например, они полезны для разной компиляции:

#ifdef __x86_64__
typedef unsigned long uint64_t;
#else
typedef unsigned long long uint64_t;
#endif

Директива #if позволяет проверить произвольное арифметическое выражение.

#define TWO 2
#if TWO + TWO == 4
// ...
#endif

Директива #if препроцессирует свой аргумент, а затем парсит то, что получилось как арифметическое выражение. Если после препроцессирования в аргументе #if остаются идентификаторы, то они заменяются на 0, кроме идентификатора true, который заменяется на 1.

Одно из применений #ifndef — это include guard, которые уже обсуждались ранее.

Константы.

Понадобилась нам, например, $\pi$. Традиционно в C это делалось через #define. Но у препроцессора, как мы знаем, есть куча проблем. В случае с константой PI ничего не случится, вряд ли кто-то будет называть переменную так, особенно большими буквами, но всё же.

А в C++ (а позже и в C) появился const. Но всё же, зачем он нужен, почему нельзя просто написать глобальную переменную double PI = 3.141592;?

  1. Во-первых, константы могут быть оптимизированы компилятором. Если вы делаете обычную переменную, компилятор обязан её взять из памяти (или регистров), ведь в другом файле кто-то мог её поменять. А если вы напишете const, то у вас не будет проблем ни с оптимизацией (ассемблер будет как при #define), ни с адекватностью сообщений об ошибках.
  2. Во-вторых, она несёт документирующую функцию, когда вы пишете const с указателями. Если в заголовке функции написано const char*, то вы точно знаете, что вы передаёте в неё строку, которая не меняется, а если char*, то, скорее всего, меняется (то есть функция создана для того, чтобы менять).
  3. В-третьих, имея const, компилятор может вообще не создавать переменную: если мы напишем return PI * 2, то там будет возвращаться константа, и никакого умножения на этапе исполнения.

Кстати, как вообще взаимодействует const с указателями? Посмотрим на такой пример:

int main() {
	const int MAGIC = 42;
	int* p = &MAGIC;
}

Так нельзя, это имеет фундаментальную проблему: вы можете потом записать *p = 3, и это всё порушит. Поэтому вторая строка не компилируется, и её надо заменить на

	const int* p = &MAGIC;

Но тут нужно вот на что посмотреть. У указателя в некотором смысле два понятия неизменяемости. Мы же можем сделать так:

int main() {
	const int MAGIC = 42;
	const int* p = &MAGIC;
	// ...
	p = nullptr;
}

Кто нам мешает так сделать? Да никто, нам нельзя менять содержимое p, а не его самого. А если вы хотите написать, что нельзя менять именно сам указатель, то это не const int*/int const*, а int* const. Если вам нужно запретить оба варианта использования, то, что логично, const int* const или int const* const. То есть

int main() {
	int* a;
	*a = 1;      // ok.
	a = nullptr; // ok.

	const int* b;       // Синоним `int const* b;`
	*b = 1;      // Error.
	b = nullptr; // ok.

	int* const c;
	*c = 1;      // ok.
	c = nullptr; // Error.

	const int* const d; // Синоним `int const* const d;`
	*d = 1;      // Error.
	d = nullptr; // Error.
}

Теперь вот на что посмотрим:

int main() {
	int a = 3;
	const int b = 42;

	int* pa = &a;        // 1.
	const int* pca = &a; // 2.
	int* pb = &b;        // 3.
	const int* pcb = &b; // 4.
}

Что из этого содержит ошибку? Ну, в третьем точно ошибка, это мы уже обсудили. Также первое и четвёртое точно корректно. А что со вторым? Ну, нарушает ли второе чьи-то права? Ну, нет. Или как бы сказали на парадигмах программирования, никто не нарушает контракт, мы только его расширяем (дополнительно обещая неизменяемость), а значит всё должно быть хорошо. Ну, так и работают неявные преобразования в C++, вы можете навешивать const везде, куда хотите, но не можете его убирать.

Константными могут быть и составные типы (в частности, структуры). Тогда у этой структуры просто будут константными все поля.