среда, 22 марта 2017 г.

[prog.c++.fantasy] Дополнительные атрибуты implies и expects в дополнение к noexcept

Недавняя тема "Не хватает мне в C++ noexcept-блоков с compile-time проверками" показала, что идея может оказаться востребованной, но пока она является еще очень сырой. Данный пост ставит целью сделать более понятное описание того, что же хочется получить. А так же показать, как эта идея может быть расширена для поддержки не только информации о выбрасывании исключений, но и произвольных атрибутов, которые нужны конкретным разработчикам.

Итак, сперва поговорим о том, почему мне хочется иметь что-то в дополнение к noexcept.

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

class my_type {
   third_party_lib::some_type a_;
   another_lib::another_type b_;
   ...
public :
   friend void swap(my_type & a, my_type & b) noexcept { // (3)
      using namespace std;
      swap(a.a_, b.a_);
      swap(a.b_, b.b_);
      ...
   }
   my_type & operator=(const my_type & o) {
      my_type tmp(o); // (1)
      swap(*this, tmp); // (2)
      return *this;
   }
   ...
};

Здесь у меня в точке (1) могут вылетать исключения, но это не страшно, т.к. ничего нигде не портится и не меняет своего состояния. А в точке (2) я могу быть уверен, что исключений не будет, т.к. это гарантируется в точке (3). Причем в точке (3) я рассчитываю на то, что нормальные реализации swap для типов из библиотек third_party_lib и another_lib не бросают исключений. Но даже если они исключения бросают, то поделать в этой ситуации я все равно уже ничего не могу и поэтому блее правильно позвать std::terminate, чем пытаться как-то выкрутиться из этой ситуации.

Именно такое поведение noexcept для функций/методов является одновременно и сильной, и слабой сторонами noexcept-а. И если про сильную сторону уже было сказано, то теперь попробую проиллюстрировать слабую сторону.

Допустим, я пишу нетривиальный деструктор класса, в котором мне нужно очистить ряд ресурсов. Какая-то часть из этих очисток может порождать исключения, я об этом знаю и меня устраивает их перехват и "проглатывание". Часть очисток для меня выглядит вполне безопасно и исключений оттуда я не жду. Ну, что-то вроде (прошу не обращать внимания на откровенный говнокод, который явно нуждается в рефакторинге):

class my_complex_actor {
   ...
public :
   ~my_complex_actor() {
      // Закрываем внешние ресурсы. Тут можно поймать system_error,
      // но поделать уже ничего нельзя, поэтому просто проглатываем его.
      try {
         close_external_resource_1();
      }
      catch(const system_error &) {
         ... // Возможно мы здесь попробуем еще что-то сделать, вроде
            // логирования, но суть в том, что пойманное исключение
            // дальше не пойдет.
      }
      // Ну и дальше еще несколько раз по образу и подобию...
      try {
         close_external_resource_2();
      }
      catch(const system_error &) { ... }
      ...
      // А теперь мы выполняем действия, которые в принципе не должны
      // бы приводить к возникновению исключений. Например, уничтожаем
      // свои подписки и информируем тех, кто подписан на нас о том, что
      // мы исчезаем. Тут должна быть только модификация каких-то списков
      // в памяти поэтому исключений не предвидится.
      forauto & s : subscriptions_ )
         s.remove_subscriber( *this );
      forauto & l : listeners_ )
         l.stop_listen( *this );
   }
...
};

Итак, в чем здесь я вижу проблему?

Дело в том, что код со временем эволюционирует и, даже если я ничего не меняю в своем коде, что-то может поменяться в сторонних библиотеках. Так, первоначально метод remove_subscriber не бросал исключений, а затем начал это делать. Причем начал это делать достаточно активно, скажем, в 50% случаев. И проблема в том, что при перекомпиляции своей программы об изменении поведения remove_subscriber я ничего не узнаю. При этом деструктор my_complex_actor продолжит оставаться noexcept методом. Но в run-time его работа с вероятностью 50% будет приводить к std::terminate, что лично мне не понравится. Я бы хотел получить какое-то предупреждение в compile-time чтобы иметь возможность переписать фрагмент своего деструктора, например, вот так (опять же говнокод, но для демонстрации сойдет):

forauto & s : subscriptions_ ) {
   try {
      s.remove_subscriber( *this );
   }
   catch(...) {}
}

Собственно, идея в том, чтобы добавить в C++ какой-то механизм для того, чтобы:

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

Например, выглядеть это может так (синтаксис приблизительный). Обещание не бросать исключения

class subscription_manager {
   ...
public :
   // Метод обещает не бросать исключений.
   void remove_subscriber(subscriber & s) [[implies(std::nothrow)]] {
      ...
   }
   ...
};

Ожидание того, что вызываемый код не бросает исключений:

[[expects(std::nothrow)]] {
   forauto & s : subscriptions_ ) {
      s.remove_subscriber( *this );
   }
}

Компилятор встречая перед блоком кода атрибут [[expects]] начинает проверять все вызовы внутри блока на предмет того, чтобы они обещали именно то, что записано в expect. Если обещают, то компиляция проходит успешно. Если не обещают, то либо возникает ошибка компиляции, либо предупреждение (пока мне кажется, что предупреждение предпочтительнее, т.к. всегда есть возможность приравнять предупреждения к ошибкам).

Компилятор так же проверяет атрибут [[implies]] для функции/метода. И выдает диагностику, если в коде функции/метода есть явные нарушения выданных ранее обещаний. По сути, запись void f() [[implies(Something)]] раскрывается во что-то вроде:

void __compiler_generated_f() [[impies(Something)]] {
   [[expects(Something)]] {
      f();
   }
}

Поэтому, если разработчик subscription_manager напишет что-то вроде:

class subscription_manager {
   ...
public :
   // Метод обещает не бросать исключений.
   void remove_subscriber(subscriber & s) [[implies(std::nothrow)]] {
      if(invalid_state)
         throw std::runtime_error("can't work in invalid state");
      ...
   }
   ...
};

То компилятор сразу даст такому разработчику по рукам за нарушение собственных же обещаний.

Мне кажется, что обещания нужно разделить на два типа: стандартные, которые может проверить сам компилятор, и пользовательские, проверку которых компилятор сделать не может в принципе.

Например, к стандартным обещаниям можно отнести такие вещи, как:

  • std::nothrow -- нет вызова функции/методов/операторов, которые бросают исключения. Например, функция выполняет только (x=x+1), где x -- это переменная типа int;
  • std::pure -- нет модифицирующих обращений к чему-либо;
  • std::norecursion -- нет рекурсивного вызова внутри функции.

Стандартные обещания нужны потому, что в каких-то случаях только компилятор может проверить, выполняется ли обещание или нет. Как в случаях с теми же std::nothrow и std::pure.

А вот пользовательские обещания компилятор проверять не в состоянии. Поэтому он может только удостовериться, что список обещаний в [[implies]] для какой-то функции совпадает с тем, что перечислено в [[expects]] для блока кода. Ну, например, кто-то может написать:

std::chrono::milliseconds
optimal_timeout() [[impiles(std::nothrow, std::pure, mylib::thread_safe)]] {
   ...
}

Компилятор разве что сможет проверить выполнение std::nothrow и std::pure. А вот выполнение mylib::thread_safe он проверить не сможет. Поэтому когда кто-то пишет:

[[expects(mylib::thread_safe)]] {
   const auto timeout = optimal_timeout();
   ...
}

То можно только надеяться на то, что optimal_timeout действительно обеспечивает thread safety.

Однако, имхо, это все равно лучше, чем текущая ситуация. Поскольку, если у меня есть что-то вроде:

std::chrono::milliseconds
optimal_timeout() [[impiles(std::nothrow, std::pure, mylib::thread_safe)]] {
   ...
}

void
put_actor_to_sleep(actor a, std::chrono::milliseconds timeout) { // (1)
   ...
}
...
[[expects(std::nothrow, mylib::thread_safe)]] { // (2)
   put_actor_to_sleep(my_actor, optimal_timeout()); // (3)
   ...
}

То я бы предпочел во время компиляции получить предупреждение о том, что в точке (3) у меня происходит нарушение моих ожиданий, задекларированных в точке (2), поскольку функция put_actor_to_sleep не дает нужных мне обещаний (нет [[implies(std::nothrow, mylib::thread_safe)]] в точке (1)). Возможно, я бы тогда переписал функцию put_actor_to_sleep. Возможно, сделал бы вокруг ее вызова какую-то обертку, которая нужные мне обещания предоставит.

Пожалуй на этом на данный момент все. Пока описано все на текущей степени понимания собственных хотелок.


Немного соображений по околотехническим деталям.

Вероятно, обещания можно представлять в виде пустых структур-тегов. Что-то вроде:

namespace mylib {
   struct thread_safe {};
   struct lock_free {};
   struct cpu_bound {};
   ...
};

Может быть (может быть), когда в C++ добавят концепты, то и концепты можно будет рассматривать в качестве подобных обещаний. Хотя я не настолько разбирался с концептами, дабы понимать, есть ли от этого какой-то профит.

Вероятно, список обещаний должен быть частью типа функции/метода, по аналогии с тем, как noexcept стала частью типа функции/метода в C++17. Тогда получится, что мы сможем объявить указатель на функцию, которая обещает std::pure, но не сможем присвоить этому указателю значение функции, которая std::pure не обещает.

Вероятно, нужно будет уметь как-то обозначать тождественность обещаний, определенных в разных библиотеках. Например, если thread_safe определен в моей библиотеке mylib и в чужой библиотеке yourlib, но при этом mylib::thread_safe и yourlib::thread_safe обозначают одно и то же, то нужно дать об этом знать компилятору. Чтобы можно было писать так:

[[expects(mylib::thread_safe)]] {
   yourlib::some_thread_safe_func(...);
}

Так же нужно будет иметь возможность описывать "поглощение" обещаний. Например, std::pure может автоматически означать mylib::thread_safe.

Вероятно, в stdlib нужно будет включить как обещания, которые компилятор гарантированно может проверить сам (как, например, nothrow и pure), так и обещания, которые компилятор не может проверить, но которые являются общеупотребительными (например, thread_safe). И тогда можно использовать имена вроде std::strict::nothrow и std::relaxed::thread_safe.

Пока для меня самого непонятно, нужно ли в [[expects]] позволять делать какую-то группировку обещаний. Что-то вроде:

[[expects(std::nothrow && (std::pure || std::thread_safe))]] {
   do_some_call();
}

Вероятно, это нужно. Но в эту сторону я пока не копал.


Ну вот как-то так. Что было в голове, то и записал.

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

Так же, судя по всему, для меня лично это будет просто неподъемная задача по созданию предложения для комитета по стандартизации C++. В том числе и потому, что у меня сейчас есть более насущные (во всех смыслах) задачи, от которых зависит и мое будущее, и будущее всех людей, за которых я несу ответственность. Так что я лично себя в качестве основного движителя не рассматриваю в ближайшие 6-9 месяцев. Но, возможно, кого-то весь этот поток сознания натолкнет на что-то большее, с лучшими перспективами успешного завершения.

Комментариев нет: