Pull to refresh

Comments 28

UFO just landed and posted this here

Очень похоже.


Вот, если мы поможем компилятору, чтобы он давал разные имена разным перегрузкам, — то всё будет работать.
https://gcc.godbolt.org/z/K8d9vv8oT


namespace a {}
namespace b {}

using namespace a;
using namespace b;

namespace a {
template<class T> void f() { std::cout << __PRETTY_FUNCTION__ << std::endl; }
}

void g() { f<int>(); }  // a::f

namespace b {
template<class T> void f() requires true { std::cout << __PRETTY_FUNCTION__ << std::endl; }
}

void h() { f<int>(); }  // b::f подошло лучше, чем a::f

Кажется, чувака по ссылке удалось переубедить.

В современном С++ это скорее норма, чем исключение. Надо иметь голову как дом советов чтобы всё понимать досконально. «Штудируй стандарт который меняется почти каждый год.»

Не соглашусь. Если не лазить по обочинам языка, то странное поведение — исключение, а не норма. А обочины были с самого рождения. Тут, конечно, С++ показывает кузькину мать.


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


А новые фичи — это с некоторой вероятностью и новые дефекты стандарта, и новые баги компиляторов. Которые рано или поздно будут устранены.


Нужно ли штудировать дефекты стандарта? Наверно, нет, до тех пор, пока именно в вашем коде эти дефекты не начинают проявляться.
Вот в коде домашней работы студента они проявились, — возник повод об этом поговорить.
Через три года, надеюсь, повод рассосётся.

Интересно, почему даже в 2021 году компилятор не может сделать два прохода по файлу, а не заставлять программиста писать предварительное объявление :(

На самом деле, компилятор делает два прохода, когда ему это нужно по стандарту.
Инлайновые определения внутри объявления класса.

Зачем же тогда все еще требуются эти предварительные объявления, если они не всегда требуются? Т_Т

Обратная совместимость. C был однопроходным, C++ старался по максимуму сохранить с ним совместимость. То есть в классах, как принципиально новых сущностях, сделали как удобно, а для свободных функций, которые были и в С сделали как совместимо. И сейчас вряд ли кто-то это будет менять, потому что неизвестно сколько кода это сломает.

Не вижу, почему "необязательная" однопроходность может сломать старый код, но да, наверняка может как-нибудь максимально неожиданно. Эх.

Пример, как можно что-нибудь сломать. Да, надуманный, но почему бы и нет.


// some.h
int f(char);

const size_t N = sizeof(f(0));
long arr[N];

// does_not_break.cpp
#include "some.h"
double f(int);

// breaks.cpp
double f(int);
#include "some.h"

В старом коде мы могли договориться, что "давайте будем следовать определённому порядку инклудов, и всё будет хорошо".
Двухпроходность эту договорённость частично отменяет, и "всё будет хорошо" уже не гарантируется.

Но ведь инклуды отрабатываются не компилятором. Если я правильно препроцессировал и скомпилировал в мозгу, то во втором случае получится что-то вроде на входе компилятора:


double f(int);
int f(char);

const size_t N = sizeof(f(0));
long arr[N];

И sizeof(f(0)) выберет double f(int). И как тут может повлиять многопроходность компилятора? А в случае C, оба вариант не должны скомпилироваться ("conflicting types for 'f'; have 'int(char)'").


Меня не покидает ощущение, что я чего-то недопонял, хочу разобраться :)

С точки зрения автора клиентского кода, инклуды - это

  • такой импорт для бедных

  • препроцессорная магия

Правила хорошего тона как бы намекают, что порядок импорта не должен влиять, если это не специально оговорено.

Именно поэтому и делают инклуд-гарды и pragma once, - чтобы все, кто надо, спокойно импортировали зависимости в свои хедеры, и чтобы эта этажерка инклудов не мешала друг другу.

Есть известные антипаттерны, как сделать больно на инклудах. Самый очевидный - (пере)определение макросов. Особенно, когда имя макроса конфликтное. Тогда код до и после инклуда будет компилироваться заметно по-разному.

Здесь мы наблюдаем более тонкий эффект: наличие/отсутствие желательных/нежелательных перегрузок функций в точке некоторого использования в хедере (определение инлайн функции, статической функции, константы и т.п.)

Если бы компиляция была двухпроходной, то все перегрузки были бы видны отовсюду, и проблема выстреливала бы всегда, и её пришлось бы чинить. А так мы получаем неочевидность и шевелёнку.

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

Именно поэтому и делают инклуд-гарды и pragma once, — чтобы все, кто надо, спокойно импортировали зависимости в свои хедеры, и чтобы эта этажерка инклудов не мешала друг другу.

Спокойно всё равно не получится, пример, когда два класса друг на друга ссылаются. Да и на практике встречал, когда порядок инклудов влиял на успешность сборки (кто-то не видел банальные uint32_t, поэтому перед подключением требовался cstdint, но это локальные косяки). Ну и делали, как мне кажется не именно из-за правил хорошего тона, а что бы не появлялось два struct foo {};, что вступало бы в конфликт и вызывало бы ошибку компиляции.


И это, кстати, хорошо. Вон, в Linux Kernel для генерации Device Tree тоже используют C Pre-Propcessor, только там не принято обкладываться инклуд-гардами и в сложном случае что-то молча переписать и получить не то, что ожидалось очень легко. Недавно баг с этим связанный разбирал. Ужасно неприятно.


Самый очевидный — (пере)определение макросов.

В курсе, как минимум привет _POSIX_C_SOURCE, как максимум, специфическая "кодогенерация".


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

Я правильно понял, что под многопроходностью понимается не несколько проходов по единице трансляции, а… как бы по всему коду? Уточняю, так как то, что заинклудилось видится только в этой единице трансляции, и как это может стать видимым в другом месте мне не совсем понятно.

Под многопроходностью понимается именно в единице трансляции. А уже потом по всему коду - это компетенция линкера :)

Естественно, что шаблоны, как и любой другой инлайн, подвержены проблеме ODR. Но эту боль мы пока трогать не будем. Когда в одной единице трансляции инлайн реализуется одним способом, а в другой - другим.

---

Если два класса ссылаются друг на друга, - для этого есть предобъявления. И написать такой код, который не будет чувствителен к порядку инклудов, - в общем-то, несложно.

Можно проворонить проблему - например, забыть `#include <cstdint>`, потому что он приехал по зависимостям.

---

Вон, в Linux Kernel для генерации Device Tree тоже используют C Pre-Propcessor, только там не принято обкладываться инклуд-гардами

Это уже не импорты, а препроцессорная магия, это другое.

Или, например, трюки со счётчиками времени компиляции.
Общая схема такая


#define SET_TAG(value) ..... // вводит новую перегрузку функции
#define LAST_TAG() ..... // лучше всего подходит к последней перегрузке

SET_TAG(123);
.....
static_assert(LAST_TAG() == 123);
.....
static_assert(LAST_TAG() == 123);
.....
SET_TAG(456);
.....
static_assert(LAST_TAG() == 456);
.....

В какой-нибудь автоматической кодогенерации, макросной магии может пригодиться.


Пример, как это можно реализовать нечувствительно к одно-двух-проходности


template<unsigned N> struct argument : argument<N-1> {};
template<> struct argument<0> {};

#define SET_TAG(V) \
    static constexpr int tag_func(argument<__COUNTER__>*) { return V; }
#define LAST_TAG() \
    tag_func((argument<__COUNTER__>*)nullptr)

https://gcc.godbolt.org/z/3vThTex4P


Тут фокус в том, что argument<N> лучше всего приводится к ближайшей базе argument<M>, где M<N.
И, поскольку __COUNTER__ монотонно растёт, то будет выбрана самая последняя из перегрузок.
static нужно для того, чтобы не нарушить ODR, если в разных единицах трансляции одинаковым __COUNTER__'ам будут соответствовать разные значения тэгов.


Однако, это не единственный способ. Я когда-то встречал реализации, где новая перегрузка утилизировала старую. Без __COUNTER__.
И вот там уже однопроходность была существенна.
Но на ходу не вспомню, как это делается.

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

В пучине разных библиотек, типа того же буста, может и не такой ад скрываться.
И тут прибегает некто с -ftwo-pass...

Кажется, процесс обсуждения С++ можно описывать семью стадиями принятия…
Как вы справляетесь с отчаянием?

Мою посуду вот в такой последовательности:


  1. Кружки и стаканы
  2. Вилки, ложки и ножи
  3. Тарелки и контейнеры
  4. Отрицание
  5. Гнев
  6. Торг
  7. Депрессия
  8. Принятие
  9. Кастрюли
  10. Сковородки

11. Противень


Мой фокус в том, что я давно нахожусь в стадии противня. Чтобы вернуться к отчаянию, мне нужно перепачкать слишком много посуды обратно.

Я думал в стадии противня только члены Комитета :D

А если добавить какой-то флаг вида -ftwo-pass? Очередной способ выстрелить себе в ногу, конечно, но как минимум осознанно.

Например, если нижние инклуды будут влиять на верхние, будет еще более печально, чем сейчас

Ну, если два инклуда содержат объявления одних и тех же функций или типов, то это, по-моему, уже сейчас стреляние в ногу.

Нет, просто очень хрупкий код. Приглашение к прилёту дятла из известной поговорки.

Sign up to leave a comment.

Articles