В C ++ void - довольно странная вещь. Мы можем преобразовывать выражения в void, throw-expression иметь void тип, void* может указывать на что угодно, а void функции могут фактически возвращаться обратно вызывающей стороне. Но у нас не может быть объектов типа void или даже написать такой тип, как void&. Объявление функции типа void f(void) на самом деле является нулевой функцией. Это немного странно, но это не то, из-за чего многие люди теряют сон.

Пока он не начнет разрушать ваш общий код - потому что он похож на vector<bool> системы типов.

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

Это в основном то, что мы хотим. Мы берем две отметки времени при вызове нашей функции (используя std::invoke, потому что, конечно, указатели на члены не вызываются…), регистрируем дельту в секундах и просто возвращаем result обратно вызывающей стороне. И это работает для всех функций (которые возвращают подвижный тип) кроме тех, которые возвращают void, что дает нам ошибку компиляции, указывающую на объявление result с:

error: 'void result' has incomplete type
     auto result = std::invoke(/* ... */)
          ^~~~~~

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

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

Это работает. И если задуматься, то странно, что он работает. Ну, во-первых, вся эта работа помещается в защиту области видимости, чтобы воспользоваться преимуществом того факта, что деструкторы локальных переменных (таких как тот, который неявно создается этим макросом) упорядочиваются после оператора return, и поэтому на самом деле это действительно так. время функции. Это… ладно, может быть, это немного странно. Но настоящей странной частью здесь является тот факт, что это очень похоже на то, что мы возвращаем объект типа void, что и вызывало у нас проблемы в нашей первой версии. Язык так не работает, void объекта нет, мы просто получаем специальное правило, которое гласит, что это работает для void.

Прохладный.

Но это только вопрос времени, когда это не работает. Мы не можем полностью реорганизовать каждую функцию, чтобы просто возвращать выражения типа void. Что нам тогда делать?

Мой личный предпочтительный подход состоит в том, чтобы представить тип вместо void, который можно свободно заменять взад и вперед, и предоставить помощники, которые упрощают переключение между void и этим новым типом по мере необходимости. За неимением лучшего названия я называю этот тип… Void. Это довольно простой тип:

Пустой тип, который можно явно создать из чего угодно. Довольно простой. Вдобавок к этому мы добавляем два псевдонима типов для преобразования между void и Void в системе типов, если это необходимо:

Итак, как нам использовать эту штуку? Основная проблема void связана с неизвестными вызываемыми объектами, которые могут его вернуть. Решение состоит в том, чтобы предоставить шаблон функции, который выполняет то же самое, что и std::invoke(), но просто возвращает Void вместо void - преимущество здесь в том, что Void является фактическим типом объекта.

Без концепций это две перегрузки, которые взаимно выводятся как SFINAE-d, одна для случая void, а другая для случая, отличного от void:

Существование этих шаблонов функций позволяет нам просто заменить invoke_void() там, где мы раньше использовали std::invoke(). Наш первоначальный пример, например, переписанный для обработки void типов возвращаемых значений, будет выглядеть следующим образом:

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

Но есть еще одна мелочь, которую я нашел очень полезной, и, вероятно, более спорную.

Моя реализация optional<T>, помимо поддержки ссылочных типов, также поддерживает void (который внутренне рассматривается как Void, optional<Void> я не разрешаю). Вы можете задаться вопросом, что optional<T&> - это просто странное написание T*, но давай, optional<void> - особенно нелепое написание bool. И… ну вроде как. Но это упрощает задачу в общих контекстах, если просто нет дыр в том, что optional поддерживает. Эта реализация также имеет все функции-члены, которые Саймон Брэнд предлагает в [P079 8].

Вопрос в том, как optional<void>::map() себя вести? Какого рода вызываемый объект должен быть? Здесь есть два варианта:

  1. Поскольку внутренне это Void, map() должен принимать унарную функцию, которая принимает аргумент Void с соответствующими квалификаторами cv-ref.
  2. Поскольку внешне это void, и у вас не может быть объектов типа void, map() должна принимать нулевую функцию.
  3. Нет серьезно, а почему у вас optional<void> ?!

Я думаю, что вариант 2 - лучший выбор здесь. Это просто кажется наиболее полезным выбором и наименее неожиданным для звонящих. Итак, чтобы избежать повторной фрагментации шаблонов, у меня на самом деле есть три перегрузки invoke_void(), а не две:

Другими словами, invoke_void(f, Void()) эквивалентно invoke_void(f) для всех f (которые фактически являются нулевыми). Это верно, даже если вы попытаетесь вызвать invoke_void с функцией, которая может фактически принимать Void в качестве аргумента. Этого просто не будет. Я еще не сталкивался со случаем, когда я действительно этого хотел ...

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

Конечно, здесь снова возникает вопрос - почему void вообще такой странный? Ответом на все эти вопросы обычно бывает что-то вроде «Потому что, С.» Хотя иногда в этом виноват какой-то более ранний язык. Я не знаю истории здесь, но было бы любопытно узнать.

В любом случае, не было бы все это проще, если бы нам не пришлось сначала работать над void? Что, если бы void был, знаете ли, просто обычным типом?

Void существует только потому, что void не является типом объекта. Но остается одна маленькая причуда: вызов функции с экземпляром void, если бы это был реальный тип, все равно должен был бы вести себя так, как если бы это был реальный аргумент. То есть рассмотрите:

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