Новый синтаксис для циклов for в C++ появился уже давно - более десяти лет назад в стандарте C++11. Идея, скрывающаяся за этим синтаксисом, не является сколь-нибудь запутанной, и практически все, кто интересуются новыми свойствами языка, быстро разобрались с тем, как этим синтаксисом пользоваться и, что важнее, как создавать свои типы, совместимые с синтаксисом range-based for. Однако, как мне кажется, именно в вопросах взаимодействия с пользовательскими типами спецификация range-based for содержит несколько интересных деталей, лежащих практически на поверхности, которые остаются незамеченными просто потому, что идиоматические подходы прекрасно обходятся и без них. Возможно, кому-то будет интересно взглянуть на них повнимательнее.

Полную спецификацию range-based for можно посмотреть по ссылке (C++11), а здесь для краткости я лишь приведу её часть в собственном переводе/переизложении. Я сфокусируюсь именно на том, как "под капотом" такого цикла формируются начальный и конечный итераторы __begin и __end, задающие диапазон итерирования по некоей сущности __range типа _RangeT. То есть в первую очередь я веду речь о следующих трех условных ветвях:

  1. Если тип _RangeT является массивом, то в качестве итераторов выступают выражения __range и __range + __bound, где __bound - это размер массива.

  2. Если тип _RangeT является классом, то выполняется поиск идентификаторов begin и end в области видимости этого класса и, если хотя бы один из них там найден, то в качестве итераторов выступают выражения __range.begin() и __range.end().

  3. В противном случае, в качестве итераторов выступают выражения begin(__range) end(__range), причем поиск имен begin и end выполняется через посредство argument-dependent lookup (ADL).

Стоит сразу заметить, что условие "В противном случае..." в третьем пункте относится как к ситуации "тип _RangeT не является классом", так и к ситуации "имена в классе найти не удалось". В обоих случаях вступает в силу третий пункт.

Здесь я намеренно взял за основу спецификацию из C++11. Она несколько отличается в формулировках от более поздних вариантов. Но об этом позже. При этом в примерах кода ниже я не ограничиваю себя только C++11.

Деталь первая: обычные массивы

Чтобы сразу убрать это простой момент из рассмотрения, заметим, что, согласно первому пункту, поведение range-based for для обычных массивов является жестко зафиксированным самим ядром языка. Range-based for для обычных массивов просто итерирует указателем по элементам массива. Это поведение невозможно переопределить.

Зачастую можно увидеть, как поведение range-based for для обычных массивов объясняют через стандартные шаблонные функции std::begin и std::end (тем самым пытаясь свести первый пункт к третьему). Это не так. Поведение для массивов жёстко определено в недрах ядра языка и на поверхность нигде не выныривает. Это, очевидно, было сделано специально для того, чтобы пресечь любые попытки переопределения. Даже если ваш массив содержит элементы пользовательского типа, пытаться перегружать или специализировать std::begin и std::end для такого массива бесполезно - это никак не повлияет на поведение range-based for.

Деталь вторая: begin и end внутри классов

На первый взгляд во втором пункте спецификации все понятно: в классе выполняется поиск методов begin и end, которые затем и используются.

Но на самом деле в тексте присутствует одна важная деталь: выполняется поиск не методов, а идентификаторов begin и end внутри области видимости класса. А это значит, что в качестве таких begin и end могут выступать не только методы класса, но и его поля (которые, разумеется, должны являться функциональными объектами). Эти поля, как, впрочем, и методы, могут быть и статическими.

#include <cstring>
#include <array>
#include <iostream>

const char *const HW[] = { "Hello", "World" };

struct S
{
  static inline auto begin = []{ return std::begin(HW); };
  static inline auto end = []{ return std::end(HW); };
};

struct T
{
  const char *s;
  
  T(const char *s = "Anubis") : s{s}, begin{this}, end{this}
    {}
  
  struct B 
  {
    T *t; 
    auto operator()() const { return t->s; } 
  } begin;
  
  struct E 
  { 
    T *t; 
    auto operator()() const { return t->s + std::strlen(t->s); } 
  } end;
};

int main()
{
  S s;
  for (auto e : s)
    std::cout << e << " "; 
  std::cout << std::endl;

  T t;
  for (auto e : t)
    std::cout << e << " "; 
  std::cout << std::endl;
}

// Вывод:
// Hello World
// A n u b i s

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

Деталь третья: begin и end снаружи классов

Речь идет о третьем пункте спецификации. Опять же, внешне все просто: выполняется поиск самостоятельных/отдельностоящих функций begin и end, которые могут быть вызваны с аргументом __range и которые затем и используются для формирования итераторов.

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

#include <iostream>

const char *const HW[] = { "Hello", "World" };
const char *const GW[] = { "Goodbye", "World" };

struct S 
{
  operator auto() const { return HW; }
};

struct T 
{
  operator auto() const { return GW; }
};

auto begin(const char *const *a) { return a; }
auto end(const char *const *a) { return a + 2; }

int main()
{
  S s;
  for (auto e : s)
    std::cout << e << " "; 
  std::cout << std::endl;

  T t;
  for (auto e : t)
    std::cout << e << " "; 
  std::cout << std::endl;
}

// Вывод:
// Hello World
// Goodbye World

В данном примере, как видите, одна и та же пара функций begin и end используется двумя разными циклами for с разными типами __range.

Но более интересной деталью тут является именно то, что в версии спецификации из C++11 напрямую используется Argument Dependent Lookup (ADL). Я не буду расписывать здесь, что такое ADL - это отдельная тема - но упомяну лишь, что ADL выполняет особый поиск имен в так называемых ассоциированных пространствах имен и ассоциированных классах. И, что важно, в процессе поиска ADL "видит" только функции или шаблоны функций, но не объекты. (Кстати, эта особенность ADL лежит в основе такой модной техники, как "ниблоиды"). Что это означает для range-based for? Это означает, что в таком варианте у нас нет возможности использовать функциональные объекты вместо функций. Следующий код является некорректным в C++11 именно по этой причине

#include <array>
#include <iostream>

const char *const HW[] = { "Hello", "World" };

struct S {};

auto begin = [](const S &) { return std::begin(HW); }; 
auto end = [](const S &) { return std::end(HW); }; 

int main()
{
  S s;
  for (auto e : s)
    std::cout << e << " "; 
  std::cout << std::endl;
}

 // error: 'begin' was not declared in this scope
 //  14 |   for (auto e : s)
 //     |                 ^

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

На самом деле на этот вопрос можно предложить вполне естественные ответы. Действительно, функциональные объекты, объявленные на уровне пространства имен, очень сильно отличаются по своему поведению от классических функций и вне темы range-based for ... но см. ниже.

Деталь четвертая: смешивание разных вариантов объявления begin и end

Спецификация однозначна: обе функции должны быть либо членами класса (т.е. объявлены по второму пункту), либо отдельностоящими функциями или шаблонами функций (т.е. объявлены по третьему пункту). Объявить begin одним способом, а end - другим не получится. Точнее, работать с range-based for такая комбинация не будет.

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

Деталь пятая: ADL умеет видеть друзей

ADL имеет ещё одно замечательное свойство: он умеет видеть объявления функций-друзей класса, даже если эти функции-друзья не объявлены явно за пределами класса. Как вы знаете, для обычного поиска имен такие функции-друзья невидимы. Благодаря этому отличию ADL мы имеем возможность объявить begin и end следующим образом

#include <array>
#include <iostream>

const char *const HW[] = { "Hello", "World" };

struct S 
{
  friend auto begin(const S &) { return std::begin(HW); }; 
  friend auto end(const S &) { return std::end(HW); }; 
};

int main()
{
  S s;
  for (auto e : s)
    std::cout << e << " "; 
  std::cout << std::endl;
}

// Вывод:
// Hello World

Пусть объявление (и определение) этих функций внутри класса не вводит вас в заблуждение: даже если внешне они объявлены внутри, членами этого класса они не являются. То есть в данном случае мы используем именно третий пункт спецификации range-based for.

Деталь шестая: так все таки ADL или не ADL?

Однако интересно заметить, что уже в C++14 версии спецификации range-based for прямое использование ADL было внезапно устранено из текста, а вместо этого просто говорится, что поиск ведется в ассоциированных пространствах имен. Мое первое впечатление от этого изменения подсказывало мне, что ссылка на ADL была устранена из текста именно для того, чтобы такой поиск умел находить и функции, и функциональные объекты. Однако ни один из существующих популярных компиляторов в этом со мной не согласен: даже в режиме C++14 соответствующий код (см. "Деталь третья") не компилируется ни в GCC, ни в Clang, ни в MSVC++.

При этом формулировка из C++14 звучит несколько странно. "Поиск ведется в ассоциированных пространствах имен"? Что же это за поиск такой? Как он себя ведет по сравнению с ADL? Как насчет поиска в ассоциированных классах? Умеет ли он видеть функции-друзья, объявленные внутри классов, как в примере выше?

Это странная ситуация сохраняется и в C++17, и в C++20... Но в C++23 в спецификацию третьего пункта снова неожиданно возвращается прямое упоминание ADL. Это, по-видимому, и объясняет поведение современных компиляторов. Подозреваю, что попытка отказаться от прямого использования ADL в этом контексте была признана дефектом и ретроспективно отменена.

Деталь седьмая: только в ассоциированных пространствах имен

Как бы там ни было, поиск отдельностоящих функций begin и end в третьем пункте делается только в ассоциированных пространствах имен (и в ассоциированных классах), определяемых типом _RangeT. Из этого следует то, что даже если вы предоставите подходящие по типу аргументов функции begin и end, но поместите их в "неправильное" пространство имен, эти функции найдены не будут. Следующий код является некорректным именно по этой причине

#include <array>
#include <iostream>

const char *const HW[] = { "Hello", "World" };

namespace N
{
  struct S {};
};

auto begin(const N::S &) { return std::begin(HW); };
auto end(const N::S &) { return std::end(HW); };
  
int main()
{
  N::S s;
  for (auto e : s)
    std::cout << e << " ";
  std::cout << std::endl;
}

// error: 'begin' was not declared in this scope
//   17 |   for (auto e : s)
//      |                 ^

В данном пример функции begin и end сидят в открытую посреди глобального пространства имен, но найдены они не будут, ибо глобальное пространство имен не является ассоциированным для типа N::S. Но если перенести эти функции внутрь пространства имен N, то они сразу начнут находиться.

Еще одним следствием этого правила является то, что поведение range-based for невозможно переопределить для фундаментальных типов. Фундаментальные типы просто не имеют ассоциированных пространств имен вообще. Поэтому вот такая наивная попытка сделать range-based for применимым к объектам типа int обречена на провал

#include <array>
#include <iostream>

const char *const HW[] = { "Hello", "World" };

auto begin(int) { return std::begin(HW); };
auto end(int) { return std::end(HW); };

int main()
{
  for (auto e : 42)
    std::cout << e << " ";
  std::cout << std::endl;
}

// error: 'begin' was not declared in this scope
//   11 |   for (auto e : 42)
//      |                 ^~

Деталь восьмая: а ведь enum - это не фундаментальный тип

Пытаться применять range-based for к значению типа int бесполезно, о чем шла речь выше, но никто нам не запрещает применить такой цикл к значению типа enum, определив begin и end как отдельностоящие функции

#include <array>
#include <iostream>

enum Dow { MON, TUE, WED, THU, FRI, SAT, SUN, N_DOW_ };

const char *const DOW_NAMES[N_DOW_][2] =
{
  { "Понедельник", "Monday" },
  { "Вторник", "Tuesday" },
  { "Среда", "Wednesday" },
  /* ... */
};

auto begin(Dow dow) { return std::begin(DOW_NAMES[dow]); }
auto end(Dow dow) { return std::end(DOW_NAMES[dow]); }

int main()
{
  for (auto e : TUE)
    std::cout << e << " ";
  std::cout << std::endl;
}

// Вывод:
// Вторник Tuesday 

Как видите, мой пример кода выше претендует на попытку создания некоего практически полезного применения для такого переопределения. Но он явно притянут за уши. Судите сами, имеет ли это смысл.

Заключение

Как вы заметили, я несколько поленился, описав вопрос "ADL или не ADL" в терминах "подозреваю" и "мне кажется". Конечно же, можно поднять документы с деталями обсуждения этого вопроса в WG21 и досконально разобраться в этапах развития этой части спецификации. У меня просто пока не было на это времени.

Также, с диапазоном итерирования range-based for связан еще ряд интересных деталей. В частности: возможность использовать итераторы __begin и __end разного типа, появившаяся в C++17. Но эта тема скорее связана с появлением поддержки диапазонов в C++, а не с range-based for.

Пока все.

Комментарии (2)


  1. voldemar_d
    15.08.2024 02:47

    появившаяся в C++17. Но эта тема скорее связана с появлением поддержки диапазонов в C++

    Если речь про std::ranges, они в стандарте появились в C++20.


    1. TheCalligrapher Автор
      15.08.2024 02:47

      Совершенно верно. Но логическая цепочка, которую я тут вижу, такова: идея разрешить end-итератору иметь тип, отличный от begin-итератора, родилась именно на основе концепции sentinels, то есть абстрактных "фиктивных" end-итераторов, которые служат лишь для того, чтобы проверить условие завершения в текущем итераторе. Считается, что sentinels возникли из Предложения N4382 Эрика Ниблера, из которого собственно и родились Ranges library (https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2015/n4382.pdf). Финализирована разработка Ranges library была лишь к C++20, но поддержка самой идеи sentinels появилась в C++ уже в C++17.