cpp-ru / ideas

Идеи по улучшению языка C++ для обсуждения
https://cpp-ru.github.io/proposals
Creative Commons Zero v1.0 Universal
88 stars 0 forks source link

Запрет на использование функций с передачей "по значению" в качестве типов параметров при объявлении функций #98

Open Neargye opened 3 years ago

Neargye commented 3 years ago

Перенос предложения: голоса +5, -2 Автор идеи: Никита Колотов

Несоответствие объявленного типа таких параметров фактическому (например в int foo(int action()) параметр action - это указатель) затрудняет работу с ними провоцирует возникновение дефектов в программах.

С давних времен в С и С++ имеется специальное правило, по которому параметры функций с типом функция T на деле получают тип указатель на T: [dcl.fct] 11.3.5 Functions 5 ... After determining the type of each parameter, any parameter of type “array of T” or of function type T is adjusted to be “pointer to T”. C параметрами-функциями сложностей меньше, чем с параметрами-массивами, но они тоже зачастую приводят к различным проблемам:

  1. игнорирование опциональности такого параметра:
    void foo(int bar(int))
    {
    cout << bar(2); // fail
    }
    ...
    foo(nullptr);
  2. неочевидная невозможность задать const квалификатор для такого параметра чтобы соблюсти const-correctness:
    
    using action_t = int (int);

void test(action_t const action) // const is not applied to pointer { action = 0; // wat }

3. могут иметь место расхождения между объявлениями функции  и / или ее определением, создавая неразбериху:
```cpp
using action_t = int (int);

void test(action_t action);
void test(action_t * p_action); // same as above
  1. дополнительные сложности в случае с most vexing parse:
    int x(int()); // парсится не как переменная x, а как объявление функции
    ...
    x = 4; // компилятор сообщит об ошибке тут, а не в месте некорректного объявления x

    Соответственно я предлагаю это правило заменить на прямой запрет таких действий: If function has parameter of type “function T” the program is ill-formed.

Neargye commented 3 years ago

Игорь Гусаров 23 января 2020, 14:13

  1. Обратная совместимость. Любое предложение, которое ломает существующий код (даже с самыми благими намерениями!), с большой вероятностью будет отклонено. Для выдвижения ломающих предложений нужно, чтобы был показан колоссальный выигрыш от них. Боюсь, что в данном случае выигрыш звучит не очень убедительно: "защититься от возможных ошибок кодирования, от которых и так можно защититься имеющимися в языке средствами".

  2. Унификация. С точки зрения generic programming лучше, чтобы все типы вели себя по возможности единообразно. Например, есть предложение P0146R1 Regular Void. Оно нацелено на унификацию войда с остальными типами, чтобы в шаблонном коде можно было написать "auto x = foo();" или "promise.set_value(foo());" даже в случае, когда foo возвращает void. Это предложение рассматривается комитетом и уже дошло до стадии пробной реализации.

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

*Никита Колотов 26 января 2020, 21:40 Игорь Гусаров, Мне кажется, что вы как-то не так поняли смысл этого предложения. На единообразие типов оно влияет только положительно, так как направлено на отмену специального правила для интерпретации типов аргументов фунции и переход к единообразным правилам на этот счет для всех типов. Заметьте, что оно затрагивает написание сигнатуры функции, т.е. для выражений "auto x = foo();" или "promise.set_value(foo());" оно вообще не применяется.

Игорь Гусаров 27 января 2020, 11:53 Никита Колотов, возможно, я действительно как-то не так понял. Вы говорите о единообразном описании типов в стандарте?

А я говорю уже о следующем шаге: как это предложение отразится на единообразии сценариев использования языка:

using T1 = int;           // Built-in integer type
using T2 = MyStruct;      // User-defined complete type
using T3 = void (*)();    // Pointer to function
using T4 = void (&)();    // Reference to function
using T5 = void ();       // Function
using T6 = void;          // Void

void foo1(T1 arg);        // 1
void foo2(T2 arg);        // 2
void foo3(T3 arg);        // 3
void foo4(T4 arg);        // 4
void foo5(T5 arg);        // 5
void foo6(T6 arg);        // 6

В настоящий момент валидными декларациями являются строки 1-5.

Рассматриваемое предложение предлагает сделать строку 5 невалидной, тем самым уменьшая количество допустимых способов использования типа T5, и усложняя разработку обобщённого кода:

template <typename T>
struct Processor
{
    void Do(T arg);
};

// With the proposal in effect,
// the code above would fail for T = T5.
// Hence, need special implementation
// for function types...
template <>
struct Processor<T5>
{
    void Do(T5& arg);
};

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

Никита Колотов 28 января 2020, 0:20 Игорь Гусаров, сначала тут надо разобраться, действительно ли имеет место единообразие сценариев. Возьмем ваш пример и добавим проверку аргумента на соответствие заявленному типу:

#include <type_traits>

struct MyStruct{};
using T1 = int;           // Built-in integer type
using T2 = MyStruct;      // User-defined complete type
using T3 = void (*)();    // Pointer to function
using T4 = void (&)();    // Reference to function
using T5 = void ();       // Function

void foo1(T1 arg){ static_assert(std::is_same_v<T1, decltype(arg)>); }// 1 ok
void foo2(T2 arg){ static_assert(std::is_same_v<T2, decltype(arg)>); }// 2 ok
void foo3(T3 arg){ static_assert(std::is_same_v<T3, decltype(arg)>); }// 3 ok
void foo4(T4 arg){ static_assert(std::is_same_v<T4, decltype(arg)>); }// 4 ok
void foo5(T5 arg){ static_assert(std::is_same_v<T5, decltype(arg)>); }// 5 err

варианты 1-4 работают, а 5 вариант внезапно вызовет ошибку. Или вот немного видоизменненый пример, домонстрирующий применение const квалификатора:

using T1 = int *;      // Pointer to built-in integer type
using T2 = int;        // Built-in integer type
using T3 = void (*) ();// Pointer to function
using T4 = void ();    // Function

void foo1(T1 const arg){ arg = 0; }// 1 err
void foo2(T2 const arg){ arg = 0; }// 2 err
void foo3(T3 const arg){ arg = 0; }// 3 err
void foo4(T4 const arg){ arg = 0; }// 4 ok

теперь наоборот, работает только вариант, принимающий Function. Еще один пример, попробуем вместо разных функций сделать перегрузки:

using T1 = int;    // Built-in integer type
using T2 = void ();// Function

void foo(T1 * arg){ }// 1 ok
void foo(T1 & arg){ }// 2 ok
void foo(T1   arg){ }// 3 ok
void foo(T2 * arg){ }// 4 ok
void foo(T2 & arg){ }// 5 ok
void foo(T2   arg){ }// 6 err

и опять с вариантом, принимающим просто Function не все в порядке...

Как видите, особого единообразия не наблюдается. Причин две:

  1. функция не может быть типом аргумента функции

Заметьте, мое предложение не уменьшает количество допустимых способов использования типа T5 - на этот тип уже наложены ограничения.

  1. когда в объявлении параметров функции в качестве типа аргумента указывается функция, то тип аргумента подменяется на указатель на функцию

И вот мое предложение направлено на отмену этого второго правила (никак не затрагивая первое): раз уж функции не могут быть типом аргументов, то пусть не будет разрашено указывать их типом аргументов. Будет чуть более единообразно - типы аргументов всегда будут соответствовать объявленным.

Игорь Гусаров 28 января 2020, 19:42 Никита Колотов,

  1. Про "функция не может быть типом аргумента функции". Мне кажется, в этом утверждении смешиваются понятия объекта и типа. Объект функции (её тело) действительно нельзя передать куда бы то ни было по значению, с этим я согласен. На объект ограничения есть. Но тип функции - сейчас можно передать, причём как раз благодаря специальным правилам. То есть на использование именно типа сейчас ограничений нет.

  2. Про пример с "arg = 0;" Боюсь, что поведение, которое демонстрирует этот пример, вызвано тем, что к уже определённому функциональному типу в принципе нельзя добавить const-квалификацию. Она игнорируется. Деклараторы "T" и "const T" для функций - это в принципе одно и то же, независимо от того, где встречается такой декларатор. Т.е. это свойство никак не связано с использованием функции в качестве аргумента, и соответственно, предлагаемый запрет никак не повлияет на данное свойство.

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

using Test = void ();
//using Test = int&;    // References are also like that.

template <typename T>
struct Probe;

// Full specialization for T = Test.
template <>
struct Probe<Test>
{
};

// Full specialization for T = const Test.
// Ooops... error: Redefinition of Probe<void()>
// Because const cannot be added
template <>
struct Probe<const Test>
{
};

Как видим, отбрасывание квалификатора const для функционального типа никак не связано с передачей агрументов в функцию. Можно заметить, что такое же поведение свойственно ссылочным типам. И это в общем-то логично, так как функции по смыслу гораздо ближе к семантике ссылки, а не значения.

  1. Про примеры с is_same_v и с перегрузкой. Вы правы, это неприятные особенности. К ним можно ещё добавить невозможность сделать копию аргумента в теле функции: T5 temp = arg;. Подобные проблемы с перегрузкой возникают и со ссылочными типами (перегрузка по T и T& в случае, когда T - ссылка). Сейчас подобных проблем можно избежать, если пользоваться std::function и std::array. Но я с удовольствием поддержу предложение, конструктивно улучшающее общую ситуацию, в частности, не ломающее существующий код и не запрещающее видимость передачи по значению, т.к. такая передача востребована в обобщённом коде.

Никита Колотов 29 января 2020, 23:14 Игорь Гусаров, 1. Не знаю, что вы подразумеваете "тип функции - сейчас можно передать". Вот в приведенном ранее примере с "void foo5(T5 arg)" тип T5 является функций, но не является типом аргмента. А правило разрешающее только видимость такой передачи является неконсистентным и только сбивает толку.

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

3 std::function не является равноценной заменой для простой передачи функций по указателю или по ссылке, хотя бы даже из-за оверхеда и невозможности использовать в constexpr.

Игорь Гусаров 30 января 2020, 15:40 Никита Колотов,

  1. Про "Не знаю, что вы подразумеваете "тип функции - сейчас можно передать"." - я имею в виду именно то, что написал в этом сообщении: что void foo5(T5 arg) является валидным объявлением функции. Т.е. что данный тип можно использовать как тип формального параметра. Это - уже сценарий. Он уже позволяет определить функцию и что-то в ней сделать. И единообразие этого конкретного сценария уже ценно.

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

  1. Про объяснение отбрасывания const. Извините, если у меня не получилось пересказать смысл [dcl.func].p7. Я старался передать факт практически дословно: "к уже определённому функциональному типу в принципе нельзя добавить const-квалификацию. Она игнорируется."

  2. Согласен, не равноценна. Но почему же Вы видите те недостатки, но не видите недостатков у запрета?