Анализ кода проекта DeepSpeech или почему не стоит писать в namespace std

    DeepSpeech – это открытый и свободно распространяемый движок распознавания речи, разрабатываемый компанией Mozilla. Движок имеет довольно высокую производительность и хорошие отзывы пользователей, и это делает код проекта интересной мишенью для проверки. Данная статья посвящена разбору ошибок, найденных в C++-коде проекта DeepSpeech.

    image1.png

    Введение


    Мы уже неоднократно искали ошибки в проектах, использующих машинное обучение, и DeepSpeech не стал для нас исключением. Не удивительно, ведь этот проект является достаточно популярным: на момент написания статьи он имеет уже более 15k звёзд на GitHub.

    Как обычно, поиск ошибок, которые я приведу в этой статье, проводился с помощью статического анализатора кода PVS-Studio.

    Для своей работы DeepSpeech использует библиотеку TensorFlow. Я отключил анализ кода этой библиотеки, потому что про неё мы уже писали отдельную статью, однако я не стал отключать анализ остальных используемых библиотек. С чем это связано? Ошибки внутри любой библиотеки, которую вы включаете в ваш проект, также становятся и ошибками внутри вашего проекта. Поэтому полезно проводить анализ не только вашего, но и любого стороннего кода, который вы используете. Подробное мнение об этом вы можете прочитать в нашей недавней статье.

    На этом краткое вступление заканчивается – пора переходить к разбору ошибок. Кстати, если вы зашли сюда, чтобы узнать ответ на вопрос, заданный мной в заголовке статьи (почему не стоит писать в namespace std) – можете сразу заглянуть в конец статьи. Там вас ждёт по-особенному интересный пример!

    Обзор 10 интересных предупреждений, выданных анализатором


    Предупреждение 1

    V773 The function was exited without releasing the 'data' pointer. A memory leak is possible. edit-fst.h 311

    // EditFstData method implementations: just the Read method.
    template <typename A, typename WrappedFstT, typename MutableFstT>
    EditFstData<A, WrappedFstT, MutableFstT> *
    EditFstData<A, WrappedFstT, MutableFstT>::Read(std::istream &strm,
                                                   const FstReadOptions &opts)
    {
      auto *data = new EditFstData<A, WrappedFstT, MutableFstT>();
      // next read in MutabelFstT machine that stores edits
      FstReadOptions edits_opts(opts);
    
      ....
      
      std::unique_ptr<MutableFstT> edits(MutableFstT::Read(strm, edits_opts));
      if (!edits) return nullptr; // <=
    
      ....
    }

    Данный фрагмент кода содержит классический пример утечки памяти: функция Read вызывает 'return nullptr', не освободив при этом память, выделенную с помощью выражения 'new EditFstData'. При подобном выходе из функции (без вызова delete data) будет удалён только сам указатель, а деструктор объекта, на который он указывает, вызван не будет. Таким образом, объект продолжит храниться в памяти, а удалить или использовать его уже будет нельзя.

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

    Предупреждение 2

    V1062 The 'DfsState' class defines a custom 'new' operator. The 'delete' operator must also be defined. dfs-visit.h 62

    // An FST state's DFS stack state.
    template <class FST>
    struct DfsState {
    public:
      ....
      void *operator new(size_t size, 
                         MemoryPool<DfsState<FST>> *pool) {
        return pool->Allocate();
      }
      ....
    }

    PVS-Studio не стоит на месте и продолжает пополняться новыми диагностиками. Данный фрагмент кода – отличный пример для того, чтобы показать на нём работу свежей диагностики под номером V1062.

    Правило, за выполнением которого следит эта диагностика, просто: если вы определяете собственный оператор 'new', то вы также должны определить и собственный оператор 'delete'. Аналогично работает и наоборот: если вы определяете собственный 'delete', то и собственный 'new' тоже надо определить.

    В примере выше данное правило нарушено: создаваться объект будет с помощью определенного нами 'new', а удаляться – стандартным 'delete'. Давайте посмотрим, что же делает функция Allocate класса MemoryPool, которую вызывает наш собственный 'new':

    void *Allocate() {
      if (free_list_ == nullptr) {
        auto *link = static_cast<Link *>(mem_arena_.Allocate(1));
        link->next = nullptr;
        return link;
      } else {
        auto *link = free_list_;
        free_list_ = link->next;
        return link;
      }
    }

    Эта функция создаёт элемент и добавляет его в связный список. Логично, что такую аллокацию следовало прописать в собственном 'new'.

    Но постойте-ка! Прямо несколькими строками ниже содержится вот такая функция:

    void Free(void *ptr) {
      if (ptr) {
        auto *link = static_cast<Link *>(ptr);
        link->next = free_list_;
        free_list_ = link;
      }
    }

    Значит, у нас уже есть готовые функции как для аллокации, так и для освобождения. Скорее всего программист должен был написать собственный оператор 'delete', использующий для освобождения именно функцию Free().

    Анализатор обнаружил еще как минимум три таких ошибки:

    • V1062 The 'VectorState' class defines a custom 'new' operator. The 'delete' operator must also be defined. vector-fst.h 31
    • V1062 The 'CacheState' class defines a custom 'new' operator. The 'delete' operator must also be defined. cache.h 65

    Предупреждение 3

    V703 It is odd that the 'first_path' field in derived class 'ShortestPathOptions' overwrites field in base class 'ShortestDistanceOptions'. Check lines: shortest-path.h:35, shortest-distance.h:34. shortest-path.h 35

    // Base class
    template <class Arc, class Queue, class ArcFilter>
    struct ShortestDistanceOptions {
      Queue *state_queue;    // Queue discipline used; owned by caller.
      ArcFilter arc_filter;  // Arc filter (e.g., limit to only epsilon graph).
      StateId source;        // If kNoStateId, use the FST's initial state.
      float delta;           // Determines the degree of convergence required
      bool first_path;       // For a semiring with the path property (o.w.
                             // undefined), compute the shortest-distances along
                             // along the first path to a final state found
                             // by the algorithm. That path is the shortest-path
                             // only if the FST has a unique final state (or all
                             // the final states have the same final weight), the
                             // queue discipline is shortest-first and all the
                             // weights in the FST are between One() and Zero()
                             // according to NaturalLess.
    
      ShortestDistanceOptions(Queue *state_queue, ArcFilter arc_filter,
                              StateId source = kNoStateId,
                              float delta = kShortestDelta)
          : state_queue(state_queue),
            arc_filter(arc_filter),
            source(source),
            delta(delta),
            first_path(false) {}
    };
    // Derived class
    template <class Arc, class Queue, class ArcFilter>
    struct ShortestPathOptions
        : public ShortestDistanceOptions<Arc, Queue, ArcFilter> {
      using StateId = typename Arc::StateId;
      using Weight = typename Arc::Weight;
    
      int32 nshortest;    // Returns n-shortest paths.
      bool unique;        // Only returns paths with distinct input strings.
      bool has_distance;  // Distance vector already contains the
                          // shortest distance from the initial state.
      bool first_path;    // Single shortest path stops after finding the first
                          // path to a final state; that path is the shortest path
                          // only when:
                          // (1) using the ShortestFirstQueue with all the weights
                          // in the FST being between One() and Zero() according to
                          // NaturalLess or when
                          // (2) using the NaturalAStarQueue with an admissible
                          // and consistent estimate.
      Weight weight_threshold;  // Pruning weight threshold.
      StateId state_threshold;  // Pruning state threshold.
    
      ShortestPathOptions(Queue *queue, ArcFilter filter, int32 nshortest = 1,
                          bool unique = false, bool has_distance = false,
                          float delta = kShortestDelta, bool first_path = false,
                          Weight weight_threshold = Weight::Zero(),
                          StateId state_threshold = kNoStateId)
          : ShortestDistanceOptions<Arc, Queue, ArcFilter>(queue, filter,
                                                           kNoStateId, delta),
            nshortest(nshortest),
            unique(unique),
            has_distance(has_distance),
            first_path(first_path),
            weight_threshold(std::move(weight_threshold)),
            state_threshold(state_threshold) {}
    };

    Согласитесь, не так-то просто найти потенциальную ошибку, верно?

    Проблема кроется в том, что и базовый, и производный класс содержат поля с одинаковыми именами: first_path. Это приведет к тому, что в производном классе будет находиться собственное, другое поле, которое своим именем перекрывает поле из базового класса. Такие ошибки могут привести к серьезной путанице.

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

    class U {
    public:
      int x;
    };
    
    class V : public U {
    public:
      int x;  // <= V703 here
      int z;
    };

    Здесь имя x перекрыто внутри производного класса. Теперь вопрос: какое значение выведет следующий код?

    int main() {
      V vClass;
      vClass.x = 1;
      U *uClassPtr = &vClass;
      std::cout << uClassPtr->x << std::endl;
      ....
    }

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

    Перекрытие имен в иерархии классов – это потенциальная ошибка, которую лучше не допускать :)

    Предупреждение 4

    V1004 The 'aiter' pointer was used unsafely after it was verified against nullptr. Check lines: 107, 119. visit.h 119

    template <....>
    void Visit(....)
    {
      ....
      // Deletes arc iterator if done.
      auto *aiter = arc_iterator[state];
      if ((aiter && aiter->Done()) || !visit) {
        Destroy(aiter, &aiter_pool);
        arc_iterator[state] = nullptr;
        state_status[state] |= kArcIterDone;
      }
      // Dequeues state and marks black if done.
      if (state_status[state] & kArcIterDone) {
        queue->Dequeue();
        visitor->FinishState(state);
        state_status[state] = kBlackState;
        continue;
      }
      const auto &arc = aiter->Value();       // <=
      ....
    }

    Указатель aiter использован после того, как он был проверен на nullptr. Анализатор делает предположение: если указатель проверяется на nullptr, значит во время проверки он может иметь такое значение.

    В таком случае давайте проследим, что случится с aiter, если он действительно будет равен нулю. Сначала этот указатель будет проверен в выражении 'if ((aiter && aiter->Done()) || !visit)'. Это условие будет равняться false, и в then-ветвь этого if мы не попадём. А дальше, по всем канонам классических ошибок, произойдет разыменование нулевого указателя: 'aiter->Value();'. Результатом такого разыменования является неопределённое поведение.

    Предупреждение 5

    Следующий пример содержит сразу две ошибки:

    • V595 The 'istrm' pointer was utilized before it was verified against nullptr. Check lines: 60, 61. mapped-file.cc 60
    • V595 The 'istrm' pointer was utilized before it was verified against nullptr. Check lines: 39, 61. mapped-file.cc 39

    MappedFile *MappedFile::Map(std::istream *istrm, bool memorymap,
                                const string &source, size_t size) {
      const auto spos = istrm->tellg();        // <=
      ....
      istrm->seekg(pos + size, std::ios::beg); // <=
      if (istrm) {                             // <=
        VLOG(1) << "mmap'ed region of " << size
                << " at offset " << pos
                << " from " << source
                << " to addr " << map;
      return mmf.release();
      }
      ....
    }

    Ошибка, найденная здесь, более понятна, чем ошибка из предыдущего примера. Указатель istrm сначала разыменовывается (два раза), а только после этого следует проверка на ноль и логирование ошибки. Это явно указывает: если в эту функцию в качестве istrm придет нулевой указатель, то неопределённое поведение (или, что более вероятно, падение программы) произойдёт без всякого логирования. Непорядок… такие баги не стоит пропускать.

    image2.png

    Предупреждение 6

    V730 Not all members of a class are initialized inside the constructor. Consider inspecting: stones_written_. ersatz_progress.cc 14

    ErsatzProgress::ErsatzProgress()
      : current_(0)
      , next_(std::numeric_limits<uint64_t>::max())
      , complete_(next_)
      , out_(NULL)
    {}

    Анализатор предупреждает нас, что конструктор инициализирует не все поля структуры ErzatzProgress. Давайте сравним этот конструктор со списком полей этой структуры:

    class ErsatzProgress {
      ....
    private:
        void Milestone();
    
        uint64_t current_, next_, complete_;
        unsigned char stones_written_;
        std::ostream *out_;
    };

    Действительно, можно заметить, что конструктор инициализирует все поля, кроме stones_written_.

    Примечание: данный пример может и не являться ошибкой. Настоящая ошибка произойдет лишь тогда, когда значение неинициализированного поля будет использовано.

    Тем не менее, диагностика V730 помогает заблаговременно отлаживать случаи такого использования. Ведь возникает закономерный вопрос: если программист решил специально проинициализировать все поля класса, то почему у него должна быть причина оставить одно поле без значения?

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

    ErsatzProgress::ErsatzProgress(uint64_t complete,
                                   std::ostream *to,
                                   const std::string &message)
      : current_(0)
      , next_(complete / kWidth)
      , complete_(complete)
      , stones_written_(0)
      , out_(to)
    {
      ....
    }

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

    Предупреждение 7

    V780 The object '& params' of a non-passive (non-PDS) type cannot be initialized using the memset function. binary_format.cc 261

    /* Not the best numbering system,
       but it grew this way for historical reasons
     * and I want to preserve existing binary files. */
    typedef enum
    {
      PROBING=0,
      REST_PROBING=1,
      TRIE=2,
      QUANT_TRIE=3,
      ARRAY_TRIE=4,
      QUANT_ARRAY_TRIE=5
    }
    ModelType;
    
    ....
    
    struct FixedWidthParameters {
      unsigned char order;
      float probing_multiplier;
      // What type of model is this?
      ModelType model_type;
      // Does the end of the file 
      // have the actual strings in the vocabulary?
      bool has_vocabulary;
      unsigned int search_version;
    };
    
    ....
    
    // Parameters stored in the header of a binary file.
    struct Parameters {
      FixedWidthParameters fixed;
      std::vector<uint64_t> counts;
    };
    
    ....
    
    void BinaryFormat::FinishFile(....)
    {
      ....
      // header and vocab share the same mmap.
      Parameters params = Parameters();
      memset(&params, 0, sizeof(Parameters)); // <=
      ....
    }

    Чтобы разобраться в этом предупреждении, предлагаю сначала разобраться, что такое PDS-тип. Аббревиатура "PDS" означает "Passive Data Structure" – простая структура данных. Иногда вместо "PDS" говорят "POD" – "Plain Old Data". Говоря простым языком (цитирую с русской Википедии), PDS-тип – это тип данных, имеющий жёстко определённое расположение полей в памяти, не требующий ограничения доступа и автоматического управления. Говоря еще проще, это тип данных, состоящий только из встроенных типов.

    Отличительной особенностью POD-типов является то, что переменные этих типов можно изменять и обрабатывать с помощью примитивных функций управления памятью (memset, memcpy и так далее). Однако про "не-PDS" типы такое сказать нельзя: столь низкоуровневое обращение с их значениями может приводить к серьезным ошибкам. Например, к утечкам памяти, двойному очищению одного и того же ресурса или к неопределённому поведению.

    На код, приведенный выше, PVS-Studio выдаёт предупреждение: так обращаться со структурой типа Parameters нельзя. Если заглянуть в определение этой структуры, можно увидеть, что второй её член имеет тип std::vector. Этот тип активно использует автоматическое управление памятью и помимо содержимых данных хранит в себе дополнительные, служебные переменные. Зануление такого поля с помощью memset может нарушить логику работы класса и является серьезной ошибкой.

    Предупреждение 8

    V575 The potential null pointer is passed into 'memcpy' function. Inspect the first argument. Check lines: 73, 68. modelstate.cc 73

    Metadata*
    ModelState::decode_metadata(const DecoderState& state, 
                                size_t num_results)
    {
      ....
      Metadata* ret = (Metadata*)malloc(sizeof(Metadata));
      ....
      memcpy(ret, &metadata, sizeof(Metadata));
      return ret;
    }

    Следующее предупреждение говорит нам о том, что в функцию memcpy передаётся нулевой указатель. Да, действительно, если функция malloc не сможет произвести аллокацию памяти, то она вернёт NULL. В этом случае этот указатель будет передан в функцию memset, где произойдёт его разыменование – и, соответственно, фееричное падение программы.

    Однако, некоторые наши читатели могут возмутиться: если память переполнилась/фрагментировалась настолько, что malloc не смог выделить память, то не всё ли равно, что произойдет дальше? Программа так и так упадёт, ведь из-за отсутствия памяти она не сможет функционировать нормально.

    Мы неоднократно сталкивались с таким мнением и считаем, что оно неверно. Я бы рассказал вам детально, почему это действительно так, но эта тема заслуживает отдельной статьи. Настолько заслуживает, что мы написали её еще несколько лет назад :) Если вам интересно, почему стоит всегда проверять указатель, возвращенный функциями типа malloc, то приглашаю вас к прочтению: Почему важно проверять, что вернула функция malloc.

    Предупреждение 9

    Следующее предупреждение вызвано теми же причинами, что и предыдущее. Правда, указывает оно немного на другую ошибку.

    V769 The 'middle_begin_' pointer in the 'middle_begin_ + (counts.size() — 2)' expression could be nullptr. In such case, resulting value will be senseless and it should not be used. Check lines: 553, 552. search_trie.cc 553

    template <class Quant, class Bhiksha> class TrieSearch {
    ....
    private:
      ....
      Middle *middle_begin_, *middle_end_;
      ....
    };
    
    template <class Quant, class Bhiksha>
    uint8_t *TrieSearch<Quant, Bhiksha>::SetupMemory(....)
    {
      ....
      middle_begin_
        = static_cast<Middle*>(malloc(sizeof(Middle) * (counts.size() - 2)));
      middle_end_ = middle_begin_ + (counts.size() - 2);
      ....
    }

    Так же, как и в предыдущем примере, здесь происходит аллокация памяти с помощью функции malloc. Возвращенный при этом указатель без всякой проверки на nullptr используется в арифметическом выражении. Увы, результат такого выражения не будет иметь никакого смысла, и в поле middle_end_ будет храниться совершенно бесполезное значение.

    Предупреждение 10

    Ну и, наконец, самый интересный на мой взгляд пример был найден в библиотеке kenlm, включенной в DeepSpeech:

    V1061 Extending the 'std' namespace may result in undefined behavior. sized_iterator.hh 210

    // Dirty hack because g++ 4.6 at least wants
    // to do a bunch of copy operations.
    namespace std {
    inline void iter_swap(util::SizedIterator first,
                          util::SizedIterator second)
    {
      util::swap(*first, *second);
    }
    } // namespace std

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

    Почему? Потому что содержимое пространства имён std определяется исключительно комитетом стандартизации. Именно поэтому международный стандарт языка C++ явно запрещает расширять std подобным образом.

    Последним стандартом, поддерживаемым в g++ 4.6, является C++03. Приведу переведённую цитату из C++03 final working draft (см. пункт 17.6.4.2.1): "Поведение C++-программы не определено, если она добавляет объявления или определения в пространство имён std или пространство имён, вложенное в std, за исключением случаев, для которых указано обратное". Эта цитата актуальна для всех последующих стандартов (C++11, C++14, C++17, и C++20).

    Предлагаю рассмотреть, как можно было исправить проблемный код из нашего примера. Первый логичный вопрос: а что это за "случаи, для которых указано обратное"? Ситуаций, при которых расширение std не ведет к неопределённому поведению, несколько. Про все эти ситуации вы можете прочитать подробнее на странице документации к диагностике V1061, но сейчас для нас важно, что одним из таких случаев является добавление специализации шаблона функции.

    Т.к. std уже имеет функцию под названием iter_swap (отмечу: функцию-шаблон), логично предположить, что программист желал расширить её возможности так, чтобы она смогла работать с типом util::SizedIterator. Только вот незадача: вместо того, чтобы добавить специализацию шаблона функции, программист просто написал обыкновенную перегрузку. Следовало написать так:

    namespace std {
    template <>
    inline void iter_swap(util::SizedIterator first,
                          util::SizedIterator second)
    {
      util::swap(*first, *second);
    }
    } // namespace std

    Однако, с этим кодом тоже всё не так просто. Дело в том, что данный код будет корректен только до стандарта C++20. Да-да, специализации шаблонов функций в нём тоже отметили как приводящие к неопределённому поведению (см. C++20 final working draft, п. 16.5.4.2.1). А поскольку данный код принадлежит библиотеке, вполне вероятно, что рано или поздно её соберут с флагом -std=C++20. Кстати, PVS-Studio различает, какая версия стандарта используется в коде, и в зависимости от этого выдает или не выдаёт предупреждение. Убедитесь сами: пример для C++17, пример для C++20.

    На самом деле можно поступить гораздо проще. Для того, чтобы исправить ошибку, достаточно лишь перенести собственное определение iter_swap в тот же namespace, в котором определён класс SizedIterator. При этом в местах, где вызывается iter_swap, нужно дописать "using std::iter_swap;". Получится так (определение класса SizedIterator и функции util::swap() изменены для простоты):

    namespace util
    {
      class SizedIterator
      {
      public:
        SizedIterator(int i) : m_data(i) {}
    
        int& operator*()
        {
          return m_data;
        }
      private:
        int m_data;
      };
    
      ....
    
      inline void iter_swap(SizedIterator first,
                            SizedIterator second)
      {
        std::cout << "we are inside util::iter_swap" << std::endl;
        swap(*first, *second);
      }
    }
    
    
    int main()
    {
      double d1 = 1.1, d2 = 2.2;
      double *pd1 = &d1, *pd2 = &d2;
      util::SizedIterator si1(42), si2(43);
    
      using std::iter_swap;
    
      iter_swap(pd1, pd2);
      iter_swap(si1, si2); // "we are inside util::iter_swap"
    
      return 0;
    }

    Теперь компилятор самостоятельно выберет нужную перегрузку функции iter_swap на основе поиска с учётом аргументов (ADL). Для класса SizedIterator будет вызываться именно версия из namespace util, а для остальных типов будет вызываться версия из namespace std. Доказательства по ссылке. Более того, внутри библиотечных функций не нужно дописывать никаких "using": поскольку их код и так находится внутри std, компилятор всё равно будет выбирать правильную перегрузку.

    И тогда – вуаля – пользовательская функция iter_swap будет работать как надо без всяких "грязных трюков" и прочего колдовства :)

    image3.png

    Заключение


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

    P.S. Мы считаем, что писать собственный код в namespace std – это плохая практика. А как думаете вы? Жду ваших ответов в комментариях.

    Если вы разрабатываете на C, C++, C# или Java и вам, как и мне, интересна тема статического анализа, то предлагаю попробовать PVS-Studio самостоятельно. Скачать его вы можете по ссылке.




    Если хотите поделиться этой статьей с англоязычной аудиторией, то прошу использовать ссылку на перевод: George Gribkov. Checking the Code of DeepSpeech, or Why You Shouldn't Write in namespace std.
    PVS-Studio
    Статический анализ кода для C, C++, C# и Java

    Комментарии 5

      +2
      Да-да, специализации шаблонов функций в нём тоже отметили как приводящие к неопределённому поведению (см. C++20 final working draft, п. 16.5.4.2.1).

      А можно это утверждение как-то аргументировать более развернуто? Поскольку в стандарте сказано:


      Unless explicitly prohibited, a program may add a template specialization for any standard library class template to namespace std provided that (a) the added declaration depends on at least one program-defined type and (b) the specialization meets the standard library requirements for the original template.

      The behavior of a C++ program is undefined if it declares an explicit or partial specialization of any standard library variable template, except where explicitly permitted by the specification of that variable template.

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

        +3
        А можно это утверждение как-то аргументировать более развернуто?

        Конечно, можно. Для начала рассмотрим цитату из стандарта C++17, п 20.5.4.2.1:


        The behavior of a C++ program is undefined if it adds declarations or definitions to namespace std or to a namespace within namespace std unless otherwise specified. A program may add a template specialization for any standard library template to namespace std only if the declaration depends on a user-defined type and the specialization meets the standard library requirements for the original template and is not explicitly prohibited.

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


        Теперь рассмотрим аналогичную выдержку из драфта стандарта C++20, п 16.5.4.2.1:


        Unless explicitly prohibited, a program may add a template specialization for any standard library class template to namespace std provided that (a) the added declaration depends on at least one program-defined type and (b) the specialization meets the standard library requirements for the original template.

        То есть, раньше была формулировка "специализация любого шаблона из стандартной библиотеки", а теперь — "специализация любого шаблона класса из стандартной библиотеки". Следовательно, специализации шаблонов функций исключили из списка того, что можно добавить в namespace std.

          0

          Понятно, спасибо.

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

        Насколько я понял, правильная перегрузка с точки зрения автора кода как раз из util, чтобы не было «a bunch of copy operations». Разве компилятор её выберет?
          0

          Да, согласно ADL он её и выберет.

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

        Самое читаемое