В последних трёх итерациях C++ не было ни одной, в которой бы не был добавлен тип-значение (англ. value type), который таковым является только на словах, а на деле это скрытая ссылка. Так, в C++17 был добавлен std::string_view, в C++20 — std::span, а в C++23 — std::mdspan. Вот об этих трёх типах речь и пойдёт в настоящей статье: о том, зачем они нужны, почему они «коварны» и что вообще со всем этим теперь делать простому программисту на C++.
string_view
По причине того, что в C++ отсутствует полноценный строковый класс, за годы его существования появилось множество различных реализаций оного. Более того, каждый серьёзный фреймворк будет иметь свою реализацию, потому что никакой эталонной так и не появилось (это крайне маловероятно в языке, который не имеет устоявшейся системы взаимодействия различных решений/библиотек), так у нас есть: QString, winrt::HSTRING, CsString, fbstring и т. д. Не все они реализуют полноценные текстовые Unicode-строки, часть из них являются просто вариацией std::string, но главное здесь другое: их очень много. А когда мы пишем приложение, нам зачастую приходится использовать множество различных библиотек, а значит, есть большая вероятность, что в нашем приложении будет использоваться больше одного строкого класса.
Что у них общего? Очень мало, но все грамотно спроектированные классы так или иначе могут быть приведены к const char*.
Здесь и далее я использую только char, хотя использование всех остальных символьных типов (wchar_t, char8_t, char16_t, char32_t) подразумевается.
А из этого следует, что, проектируя нашу будущую функцию parse(Type arg), мы можем рассмотреть следующие варианты Type:
const char*. Предусловие: переданная строка должна быть нуль-терминированной.
const std::string&.
const char* str, std::size_t size. Одного аргумента недостаточно, нужно два, но зато строка может быть любой. Предусловие: строка должна быть не меньше, чем size.
Третий вариант я отметаю сразу: типичное C-API, ему не место в C++-коде. А вот с двумя другими уже сложнее, у каждого из них свои достоинства и недостатки. const char* является абсолютно «дубовым» типом, с которым сложно работать. Для его разбора придётся сначала посчитать длину строки, а потом использовать функции в стиле C для работы с содержимым. Т. е. стандартная библиотека, boost и всё остальное остаётся за бортом. Ну или уже внутри функции parse мы создаём какую-то обёртку над голым const char*, и уже с ней работаем. В целом это не очень удобный вариант, который довольно широко используется. Но есть у этого типа несомненный плюс: внутренняя реализация любого типа строки может быть представлена через const char*, а значит, передавая QString, winrt::HSTRING и т. д. в parse, мы можем быть уверены, что никакого копирования не происходит — мы работаем с оригиналом.
const std::string&, с другой стороны, является полноценным объектом, с которым умеет работать очень многие библиотеки. Это куда более современный метод работы со строками в C++. Но есть у него один неприятный недостаток: если у нас есть объект, скажем, CsString, а функция у нас принимает const std::string&, то мы должны сначала извлечь буфер из оригинала, создать из него новый объект std::string, а затем уже передавать его в функцию. Это лишний объект и потенциально лишнее выделение памяти. Дорого ли это? Зависит от задачи, но если строки у нас большие и их много, то это может реально сказаться на производительности.
В результате у нас есть два подхода, каждый из которых плох по-своему. Поэтому многие и стали разрабатывать свои адаптеры, которые бы брали лучшее из обоих подходов, избегая их недостатков. Имён этих классов за годы скопилось немало, но в стандарт попал класс с именем std::string_view. Это очень простой класс, данные которого могут выглядеть так:
class string_view
{
//...
private:
const char* m_Data;
size_t m_Size;
};
Т. е. мы храним указатель на начало данных, а также размер этих данных. Это позволяет нам создавать объекты string_view из любых строк. Давайте напишем минимальный конструктор для наших случаев выше:
string_view(const char* str):
m_Data{str},
m_Size{std::strlen(str)}
{}
string_view(const std::string& str):
m_Data{str.c_str()},
m_Size{str.size()}
{}
Можно продолжать добавлять конструкторы, в том числе и те, что принимают итераторы, позволяющие создать string_view, смотрящий внутрь строки, но для примера этих двух достаточно. Есть три ключевых свойства string_view:
string_view не хранит данные, только ссылается на них. Поэтому объект string_view всегда должен жить дольше, чем тот, на который он ссылается.
string_view не позволяет изменять данные, на которые ссылается. Что немудрено, имея конструктор, принимающий const char*.
Объект string_view не является нуль-терминированной строкой. Это следует из того, что он может указывать внутрь другой строки, в которой этот самый нуль ещё далеко и не входит в диапазон, видимый из string_view.
Ни одно из этих свойств никак не мешает нам применить string_view к нашей функции, поэтому мы смело можем делать сигнатуру следующей: parse(std::string_view). Заметьте, мы передаём объект по значению, потому что он довольно скромного размера и передача по ссылке будет только вредить. Это уже ссылка, не надо на неё ссылаться ещё раз. После этого изменения функция parse может принимать любую строку, при этом не создаётся лишних копий, и внутри самой функции удобство работы с объектом string_view не должно уступать string (потому что по большей части интерфейс string реализован в string_view).
Так что, мы нашли идеальный вариант и теперь аргументы функций типа (const char* str) и (const std::string& str) канут в Лету? (const char* str) я бы туда отправил с превеликой радостью, а вот (const std::string& str) ещё придётся пожить.
Давайте рассмотрим следующую цепочку вызовов:
void first(std::string_view str)
{
//...
}
void second(const std::string& str)
{
first(str);
}
void third(std::string_view str)
{
second(std::string{str});
}
void fourth(const std::string& str)
{
third(str);
}
Из-за того, что у нас в цепочке смешаны string и string_view получается ситуация, в которой приходится создавать строку просто для того, чтобы мы могли её передать в качестве параметра. Этой проблемы не было бы, если бы мы использовали только string или только string_view. Но в общем случае этого добиться достаточно сложно, потому что кода много, написан он в разное время и разными людьми, поэтому таких ситуаций не избежать, если вы начнёте внедрять string_view в свой код. Если весь ваш код находится под вашим контролем, то, возможно, полный переход на string_view целесообразен, но если нет, то, вероятно, трогать уже написанное не имеет особого смысла, потому что избавиться от смешанных цепочек всё равно не удастся. Тем не менее я полагаю, что со временем string_view должен вытеснить string из параметров функций, и таких цепочек станет совсем мало.
На эту тему высказался и Джосаттис: «никогда не создавайте string из string_view» и «не имейте цепочек, где смешивается string и string_view». Я не согласен с обоими этими утверждениями (про цепочки уже поговорили): делая string_view аргументом функции, вы задаете её интерфейс, вы не должны смотреть в реализацию, чтобы определять интерфейс. Несомненно, бывают случаи, когда реализация диктует интерфейс и вы просто не можете не учитывать её, но это не тот случай. Давайте рассмотрим два простых класса:
class Date
{
public:
Date(std::string_view strDate);
};
class Person
{
public:
Person(std::string_view name);
};
Очевидно, что Date не будет хранить строку внутри себя, а просто разберёт её и использует для инициализации других данных. А вот Person, скорее всего, сохранит её как есть. Исходя из совета Джосаттиса, конструктор Person должен использовать string, но с моей точки зрения это не имеет никакого смысла. Во-первых, имея два таких класса, мы прямо говорим пользователю: используя в интерфейсе string, мы внутри храним строку в string. Т. е. у нас через интерфейс «протекает» реализация. Во-вторых, что если мы потом решим сменить внутреннее представление на QString, нам интерфейс менять? Вот будет забавно иметь такую цепочку инициализации для нашего Person: QString -> string -> QString.
В общем, с моей точки зрения, string_view нужно использовать вместо const string& в параметрах функции везде, где требуется работа с неизменяемой строкой и не требуется нуль-терминированная строка. Это отвязывает нас от string и иногда улучшает производительность. Но если многие упирают на то, что использование string_view делает код быстрее и поэтому должен использоваться, я исхожу из другого принципа: string_view делает код более общим, интерфейс становится лучше, и поэтому данный тип должен использоваться.
Но если нам нужна нуль-терминированная строка, то string_view теряет свою привлекательность. К примеру, мы делаем какую-то обёртку над C-API, которая принимает нуль-терминированную строку const char*:
void CWrapper(const std::string& str)
{
c_function(str.c_str());
}
Мы могли бы использовать string_view, но тогда нам бы пришлось создавать string внутри и потом передавать в c_function, а это очевидная пессимизация с неясными выгодами. Можно сказать, что реализация диктует интерфейс в данном случае, но можно взглянуть на это иначе: наша функция принимает нуль-терминированную строку, это часть интерфейса, а string_view таковой не является. Была предпринята попытка добавить в стандарт cstring_view, но она провалилась.
Прочее применение
Разобравшись с тем, как string_view предполагалось использовать по задумке авторов, давайте разберёмся с другими вариантами. Первым из них является создание глобальных строковых констант. Как мы раньше их создавали? Так:
const char* const c_Welcome = "Welcome!";
Или так:
const char c_Welcome[] = "Welcome!";
Реже, так:
const std::string c_Welcome{"Welcome!"};
Теперь же мы можем создавать их так:
inline constexpr std::string_view c_Welcome{"Welcome!"};
Или так:
using namespace std::string_view_literals;
inline constexpr auto c_Welcome{"Welcome!"sv};
Вариант со string удобен, но потенциально неэффективен, const char* const — быстр, но неудобен. string_view совмещает удобство с быстротой и, на мой взгляд, должен стать эталонным типом для строковых констант.
Но когда мы уходим от глобальных строковых констант в сторону локальных строковых же переменных, ситуация меняется кардинальным образом. Давайте рассмотрим довольно простой пример, который может легко встретиться в типичном C++-коде, только со string_view вместо string:
std::string strVar{"Hello,"};
std::string_view strView{strVar};
strVar += " cruel and undefined world!";
std::println("strVar: {}", strVar);
std::println("strView: {}", strView);
Первое, что мы вообще ожидаем от этого кода, к��к он должен работать? Рискну предположить, что написавший такой код человек, скорее всего, ожидает вывода Hello, cruel and undefined world!, а не Hello,. Но данный код никогда не выведет полной строки, в лучшем случае он выведет Hello,, потому что string_view ссылается на участок строки в момент создания strView, последующее изменение strVar на strView никак не отражается.
Второе, так что же код выведет? Неизвестно. В коде потенциальное неопределённое поведение (НП), потому что изменение строки может привести к выделению памяти и перемещению внутреннего содержимого, старое содержимое при этом перестаёт существовать. А именно туда, в старую память, продолжает указывать strView. Потому что string_view — это скрытая ссылка/указатель, которая выглядит иначе, не имеет тех достоинств, что имеют настоящие ссылки, но имеет все минусы оных. Другой пример:
std::string generateRandomName();
//...
std::string_view strView = generateRandomName();
std::println("strView: {}", strView);
Что выведет этот код? Опять НП, но на этот раз куда более явное: мы ссылаемся на временный объект, который уничтожается сразу после создания strView. Если же мы поменяем string_view на const string&, то всё сразу станет определённым и корректным:
std::string strVar{"Hello,"};
const std::string& strView{strVar};
strVar += " cruel and undefined World!";
std::println("strVar: {}", strVar);
std::println("strView: {}", strView)
std::string generateRandomName();
//...
const std::string& strView = generateRandomName();
std::println("strView: {}", strView);
В первом случае у нас сохраняется ссылка на сам объект string, а значит, мы получаем доступ ко всем его изменениям, и на нас никак не влияет то, что внутренний буфер поменялся. А во втором случае вступает в действие свойство ссылки на константу, которая продлевает жизнь временного объекта на срок времени жизни ссылки.
Кто-то может посчитать, что в моих примерах всё просто и тут не ошибёшься, но даже если согласится с этим (я не соглашусь), то код обычно куда сложнее и «размазаннее», и, введя переменную типа string_view, вы очень сильно рискуете нарваться на неприятности. Когда string_view только появился, я для себя сразу решил, что буду использовать этот тип только в аргументах функции, при написании реального кода. Но внутренний «оптимизатор» меня всё равно попутал, и однажды я решил использовать string_view в коде тестов: «Тут точно ничего не произойдёт, к тому же это ведь не „реальный код“!» Закончилось это всё длительной отладкой тестов, которые падали по «мистической» причине. Хорошо хоть это всё произошло довольно близко к моменту написания, и отладка была не сильно болезненной, но с тех пор у меня полное табу на переменные типа string_view в любом коде — оно того не стоит.
Наконец, string_view можно использовать в качестве возвращаемого значения. Можно даже придумать реально интересный пример, который будет достойно представлять string_view на этом поприще:
class Path
{
public:
Path(std::string_view path):
m_Path{path}
{}
std::string_view filename() const
{
//...
}
//...
private:
std::string m_Path;
};
Мы храним внутри объекта полный путь, а с помощью функций доступа получаем кусочки пути. Очень удобно, string_view как раз хорош в такой работе, потому что позволяет получить доступ к целому, не создавая копий. Отлично, давайте найдём имя файла по переданному пути:
auto fileName = Path{"/undefined/behavior/test.txt"}.filename();
std::println("File name: {}", fileName);
Это вполне типичный код: часто бывает, что нам нужен только один компонент пути. Жаль, что мы опять получаем НП... Да, эта проблема решается довольно просто:
std::string_view filename() const &
{
//...
}
std::string filename() const &&
{
//...
}
Но теперь у нас по два метода на каждый компонент, с разными возвращаемыми значениями, кроме того, где гарантия, что в реализации filename() (или другого) программист не совершит ошибку и не будет возвращаться временный объект? В общем, это рабочее решение, но мне оно не нравится. Да, если от этого получается действительный выигрыш, то я согласен, что подобное решение имеет право на жизнь. Но его должен писать хороший C++-программист, а другие, не менее хорошие, тщательно проверять. Потому что очень легко сделать так, что на выходе получим НП. Из-за того, что написание функций, возвращающих string_view, является делом сложным и опасным, такие функции просто должны быть запрещены .
Все последние годы C++ последовательно идёт к избавлению от старых методов, которые при неаккуратном использовании порождают небезопасный код. Так, мы практически перестали использовать new/delete явно, «сырые» указатели — моветон и т. д. Если вы почитаете любые советы вокруг string_view, то всё, что касается переменных этого типа и использованию его в возвращаемых значениях, будет обильно полито различными «убедитесь в правильности», «будьте осторожны» и т. п. Когда ты встречаешь подобные замечания — это очень хороший признак того, что в таких случаях тип лучше вообще не использовать. Благо, у string_view есть основное назначение, где его использование полностью безопасно.
span
Ситуация со span выглядит почти полной копией ситуации со string_view: из-за того, что в языке C++ «полноценные» массивы отсутствуют, проектируя функцию, которая должна принимать непрерывную (англ. contiguous) последовательность объектов типа T, размер которой на этапе компиляции не известен, мы рассматривали следующие варианты:
T* array, std::size_t size. Предусловие: массив должен быть не меньше, чем size.
const std::vector<T>&.
Iter begin, Iter end, где Iter — это шаблонный параметр, который подразумевает итератор.
Проблема первого варианта очевидна: это неудобный C-код. Второго — неочевидна, но она тоже есть: на std::vector свет клином не сошёлся, и, как и в случае с std::string, в «природе» есть очень много различных реализаций непрерывных массивов: QVector, std::inplace_vector, folly::small_vector, folly::fbvector и т. д. В теории, третий вариант покрывает все наши нужды и соответствует духу C++ (скорее, духу STL), но с ним есть две проблемы:
Слишком обобщённый: нашей функции нужен непрерывный массив, а мы принимаем любые итераторы. В общем случае невозможно гарантировать непрерывность.
Неудобный. Изначальный дизайн STL на итераторах, в плане удобства, давно себя дискредитировал. Писать постоянно find(vec.begin(), vec.end()) вместо find(vec) избыточно, поэтому продолжать плодить такие интерфейсы, при наличии альтернативы,— преступно.
Причём вторая проблема, как мне кажется, превалирует, потому что требование непрерывности встречается не так уж и часто.
Но с появлением std::span у нас больше нет нужды в этих старых вариантах — он их все заменяет. Как и string_view, реализация span довольно проста, но есть и разница:
template<typename T, std::size_t Extent>
class span
{
T* m_Data;
};
template<typename T>
class span<T, std::dynamic_extent>
{
T* m_Data;
size_t m_Size;
};
Во-первых, span является шаблоном, потому что предназначен к использованию с любыми типами, а не только с символьными. Во-вторых, его реализация разделена на две, что позволяет создавать объекты span меньшего размера, если размер массива, на который ссылается span, известен на этапе компиляции. К примеру, такой код:
std::array array{1, 2, 3, 4};
std::vector vec{1, 2, 3, 4};
std::println("Size of span over array: {}", sizeof(std::span{array}));
std::println("Size of span over vector: {}", sizeof(std::span{vec}));
Выведет следующее на x86-64:
Size of span over array: 8
Size of span over vector: 16
Это приятная особенность, которая практически ничего не стоит в плане реализации, но, на мой взгляд, скорее бесполезна на практике. В коде выше мы использовали CTAD при создании наших span, но при передаче в функцию такой трюк работать не будет; нам нужно в интерфейсе явно указать, какой тип мы принимаем, к примеру:
void printArray(std::span<const int> view)
{
std::println("Array: {}", view);
}
При таком интерфейсе sizeof(view) == 16, потому что по умолчанию Extent == std::dynamic_extent. Мы могли бы написать функцию printArray так, чтобы Extent определялся правильно для объектов, размер которых известен статически, но это бы привело к большому количеству шаблонного кода, который обычный программист писать никогда не станет — овчинка выделки не стоит. Когда будет стоить — напишет, конечно, но в общем случае интерфейсы будут выглядеть как выше, а значит, размер объекта будет максимальным.
Как вы наверняка заметили, в качестве аргумента шаблона выше у нас используется const int. Дело в том, что, в отличие от string_view, span является модифицирующим представлением (англ. view) по умолчанию, и если бы мы использовали сигнатуру вида std::span<int> view, то внутри функции printArray мы могли бы модифицировать элементы нашего массива. Таким образом, std::span<const T> является заменой const std::vector<T>&, а std::span<T> — std::vector<T>&.
Все плюсы и минусы span повторяют таковые у string_view, поэтому и рекомендации к применению те же самые: std::span<const T> является отличной заменой всех других вариантов в качестве параметра функции, если ограничение по приёму только непрерывных массивов вам подходит. Использование span в качестве переменных и возвращаемых значений опасно, ведёт к созданию небезопасного кода и поэтому крайне не рекомендуется.
Хотя span и является заменой vector в аргументах, всё же существует один способ использования, где они расходятся:
void printArray(std::span<const int> view)
void printVector(const std::vector<int>& view)
printArray({{1, 2, 3, 4, 5}});
// Компилируется только с C++26
//printArray({1, 2, 3, 4, 5});
printVector({1, 2, 3, 4, 5});
Из-за того, что в span нет конструктора, принимающего std::initalizer_list, мы не можем использовать printArray так же, как printVector. Мы вынуждены добавлять лишние скобки, чтобы был вызван конструктор span, принимающий статический массив (span(int (&arr)[N])). Это, безусловно, мелочь, но она не позволяет просто взять и заменить все const std::vector<int>& на std::span<const int>; приходится прибегать к «шаманству» с количеством скобок. Эта разница будет устранена в грядущем C++26.
mdspan
Если ситуация со строками и одномерными массивами имела свои проблемы, то с многомерными было всё проще: нет многомерных массивов — нет проблемы! Нет, конечно, у нас есть унаследованный из C синтаксис создания оных: int matrix[3][3], но он позволяет создавать только многомерные массивы, размеры которых известны на этапе компиляции. Кроме того, после создания с ним невозможно нормально работать в любом контексте, за исключением непосредственно места его создания. Что значит «нормально»? Это значит, не передавая в каждую функцию его размеры явно. Если для использования одномерных массивов C++ уже много лет предлагает std::vector, то для многомерных он не предлагал ничего. Аж до 2023 года.
В C++23 наконец-то появился тип, который должен сделать бессмысленным задания на написания класса матрицы в университете, но мотивация добавления std::mdspan всё же была несколько иной. Хотя, положа руку на сердце, никакой мотивации здесь не было нужно: для такого универсального языка, как C++, стыдно не иметь нормальных средств работы с многомерными массивами, которые встречаются в огромном количестве задач в совершенно разных средах.
Но mdspan пришёл к нам не один. Все, кто хоть раз писал класс матрицы на C++, знают, что при реализации обращения по индексу всегда приходилось идти на компромисс. Т. е., имея некий объект класса Matrix m{...}, мы обращались к его содержимому одним из двух способов: m[1][2][3] или m(1, 2, 3). Мы либо переопределяли operator[], последовательно возвращая нечто, что в конечном итоге позволяло нам обратиться непосредственно к содержимому, либо переопределяли operator(), возвращая искомый элемент сразу. Первый способ повторяет собой работу с многомерными C-массивами, но является сложнее в реализации и использовании (ошибка в количестве скобок будет приводить к не самым очевидным ошибкам компиляции). Второй способ лёгок в реализации и использовании, но непривычен для языка C++: тут так функции вызываются, а не доступ к массивам производится.
Т. к. оба варианта обладают своими недостатками, в C++23 был предложен вариант, который их лишён: m[1, 2, 3]. Это стало возможно благодаря предложению, снявшему ограничение с переопределяемого operator[] на один аргумент. Теперь, как и operator(), он может принимать неограниченное количество аргументов. И нет ничего удивительного, что mdspan сразу же его получил — для него он и добавлялся в первую очередь.
Так что же такое mdspan, как именно он решает проблему с нехваткой многомерных массивов в C++? Можно было бы сказать, что mdspan для многомерных массивов — это как span для одномерных. Тем более, что span есть в обоих именах. С одной стороны, это так и есть: есть целый зоопарк типов, которые реализуют многомерные массивы, и mdspan может стать единым для них интерфейсом, но, как мне видится, если он так и будет использоваться, то это будет лишь малой долей его применения.
Все мои рассуждения касательно того, как mdspan будет и/или должен применяться, являются не более чем догадками, потому что с линейной алгеброй непосредственно я не сталкивался уже лет двадцать, а значение слова тензор узнал совсем недавно. Поэтому я постарался не увлекаться с прогнозами и рекомендациями.
Основная же модель использования mdspan видится мне в использовании его как многомерного массива в C++ коде, в котором соответствующего класса ещё нет . Давайте рассмотрим более подробно, что же mdspan из себя представляет; вот как можно представить данные типичной реализации:
template<typename T,
typename Extents,
typename LayoutPolicy,
typename AccessorPolicy>
class mdspan
{
private:
HandleType m_Data;
LayoutType m_Layout;
AccessorType m_Accessor;
};
Как и в случае со span, mdspan может воспользоваться знанием о размерности массива на этапе компиляции, тем самым сократив свой отпечаток в памяти:
std::vector<int> vec{1, 2, 3, 4, 5, 6, 7, 8, 9};
auto dynMdspan = std::mdspan{vec.data(), 3, 3};
auto statMdspan = std::mdspan<int, std::extents<size_t, 3, 3>>{vec.data()};
std::println("With dynamic extents: {}", sizeof(dynMdspan));
std::println("With static extents: {}", sizeof(statMdspan));
Этот код может вывести следующее:
With dynamic extents: 24
With static extents: 8
Только вот, в отличие от span, здесь не всё так просто и однозначно. Первое, что бросается в глаза, это, безусловно, «шапка» шаблона, в которой аж на два типа больше, чем в span, второе — три переменных-члена класса, ни один из которых не является простым указателем, как это было с двумя предыдущими классами. mdspan — это куда более сложный класс, а следовательно, его финальный размер будет зависеть от многих факторов. Тем не менее можно считать, что его размер варьируется от одного до трёх машинных слов, и он подпадает под понятие малого объекта в C++ Core Guidelines, а значит, должен передаваться в функцию по значению.
Но вернёмся к примеру и посмотрим на то, как мы создавали объект mdspan: std::mdspan{vec.data(), 3, 3}. Заметьте, что мы не создаём его прямо из vector, мы передаём в конструктор «сырой» указатель. Дальше мы указываем размерность: 3×3. Благодаря всё тому же функционалу CTAD, мы получаем:
std::mdspan<int, std::extents<size_t, std::dynamic_extent, std::dynamic_extent>,...>
Это ещё одно отличие от span, у которого Extent по умолчанию выставляется в std::dynamic_extent, здесь никаких умолчаний для Extents нет, и без CTAD нам бы пришлось указывать всё руками.
Результирующее представление будет интерпретировать vec.data() как матрицу размерностью 3×3, следовательно, vec.data() должен указывать на массив из не менее чем 9 элементов типа int. Таким образом, в нашем примере HandleType будет обычным int*. Осталось разобраться с двумя параметрами шаблона: LayoutPolicy и AccessorPolicy, аргументы которых в вышеприведённом коде спрятались за многоточием.
LayoutPolicy отвечает за вычисление результирующего индекса из набора переданных индексов. К примеру, если мы запросим у нашего многомерного представления элемент dynMdspan[1, 1], то {1, 1} как-то должен сначала быть пересчитан в индекс нашего vec.data(), прежде чем мы сможем что-либо вернуть. Вот за это и отвечает LayoutPolicy.
По умолчанию этот шаблонный параметр выставляется в std::layout_right, который высчитывает индекс согласно построчному (англ. row-major) размещению элементов в памяти, т. е. такому методу размещения, при котором строки матрицы размещаются друг за другом. Имея размер строки, равный 3, получается 1*3 + 1 = 4. Построчное представление матрицы в памяти является привычным для C/C++ (и в целом для сугубо программистских задач), потому что именно так тут размещаются многомерные массивы. Немудрено, что это является поведением по умолчанию.
Я использую матричный жаргон в описании выше и далее, но C++ поддерживает тензоры любого размера, а следовательно, моя терминология описывает лишь частный случай. Тем не менее я считаю, что человеку, далекому от тензоров, гораздо легче понять частный пример с матрицами, а те, кто с тензорами на «ты», смогут без проблем экстраполировать эти частные примеры. Если всё же хочется более подробно почитать про методы размещения в обобщённом виде, то могу порекомендовать эту англоязычную статью, да и в справке от Microsoft есть хорошие примеры.
Но программистские задачи разнятся, да и программирование сейчас значительно отличается от того, что было, а в математике очень часто матрицы представляются как набор колонок, а не строк, как это принято в программировании. Поэтому ничего нет удивительного в том, что Fortran выбрал постолбцовый (англ. column-major) метод размещения матриц. В результате получились два лагеря, в каждом из которых есть свои представители. Лагеря растут, количество лагерей тоже растёт, поэтому в C++23 есть поддержка ещё двух размещений: std::layout_left для постолбцового размещения и std::layout_stride, который является обобщённым случаем первых двух и позволяет использовать произвольный шаг (англ. stride), используемый для вычисления индекса. Давайте рассмотрим пример с двумя разными методами размещения:
template <typename Extents, typename Layout = std::layout_right>
requires (Extents::rank() == 2)
void printMatrix(const std::mdspan<int, Extents, Layout>& matrix)
{
for(size_t i = 0; i < matrix.extent(0); ++i)
{
for(size_t j = 0; j < matrix.extent(1); ++j)
std::print("{} ", matrix[i, j]);
std::print("\n");
}
}
std::vector<int> rowMajor{1, 2, 3, 4, 5, 6, 7, 8, 9};
std::vector<int> colMajor{1, 4, 7, 2, 5, 8, 3, 6, 9};
std::println("Row-major stored matrix:");
printMatrix(std::mdspan{rowMajor.data(), 3, 3});
std::println("Column-major stored matrix:");
printMatrix(std::mdspan{colMajor.data(), 3, 3});
Этот код выведет следующее:
Row-major stored matrix:
1 2 3
4 5 6
7 8 9
Column-major stored matrix:
1 4 7
2 5 8
3 6 9
Этот вывод, очевидно, неверен: мы взяли матрицу, которая хранится в постолбцовом формате (colMajor), но создали mdspan с построчной интерпретацией (т. е. с аргументом по умолчанию std::layout_right). Чтобы правильно вывести матрицы, мы должны явно указать, что у нас другой формат её хранения:
std::println("Row-major stored matrix:");
printMatrix(std::mdspan{rowMajor.data(), 3, 3});
std::println("Column-major stored matrix:");
std::mdspan<int, std::dextents<size_t, 2>, std::layout_left> colMajorView{colMajor.data(), 3, 3};
printMatrix(colMajorView);
Теперь вывод будет правильный:
Row-major stored matrix:
1 2 3
4 5 6
7 8 9
Column-major stored matrix:
1 2 3
4 5 6
7 8 9
Из-за того, что нам пришлось указывать явно способ размещения данных в исходном массиве, необходимо явно указывать все предыдущие аргументы. std::dextents<size_t, 2> указывает на то, что представление будет двумерным массивом, а размер каждого измерения будет передан в конструкторе. В C++26 будет добавлено больше стандартных методов размещения, но если вам и их не будет достаточно, всегда можно реализовать свой.
После того как с помощью LayoutPolicy был вычислен результирующий индекс I, mdspan обращается к AccessorPolicy, чтобы получить элементы. Происходит это примерно так (в терминах ранее объявленного mdspan): m_Accessor.access(m_Data, I). Т. е. mdspan напрямую к массиву не обращается, доступ происходит косвенно, через AccessorPolicy и дескриптор. Таким образом полностью абстрагируется доступ к хранилищу элементов, позволяя хранить данные в любом удобном формате, в том числе вообще их не хранить, а генерировать на лету! По умолчанию этот параметр выставлен в std::default_accessor, который просто берёт данные из массива: m_Data[I].
Несложно заметить, что mdspan является очень мощным и гибким классом, который может удовлетворить любые нужды по работе с данными тензоров, в каком бы виде они ни были представлены. При этом в своём базовом виде он довольно прост в работе и для программистов, которым линейная алгебра не нужна: мы можем легко создать двумерное представление и работать с ним, благо данных, представленных в таком виде, в любой сфере хватает.
Но не нужно забывать, что mdspan — это всего лишь представление, и все проблемы, которые описаны ранее для string_view и span, здесь никуда не деваются. Тем не менее назначение mdspan и string_view со span совершенно разное, поэтому к mdspan нельзя применять те же принципы. Ввиду этого я вполне допускаю, что мы можем увидеть применение mdspan там, где представлению делать нечего: в членах класса. Но такова цена того, что mdspan — это не просто интерфейс к существующим стандартным решениям, это единственный стандартный метод работы с многомерными массивами, и с этим придётся жить. Правда, я всё равно считаю, что его применение можно свести к параметрам функций и очень коротко живущим локальным переменным при острой необходимости, но поживём — увидим.
Заключение
В статье мы рассмотрели три типа, которые по всему выглядят как обычные типы-значения, а на деле являются ссылками. И, на мой взгляд, это является большой проблемой для неофитов и «сезонных» программистов на C++, их слишком легко использовать неправильно. Они очень похожи между собой с точки зрения духа реализации, но при этом их применение и применимость может разниться довольно сильно. К примеру, я считаю string_view типом, который со временем будет доминировать в C++-коде, заменив string в интерфейсах.
span, будучи близнецом-братом string_view, с другой стороны, выглядит не столь заманчиво: да, им можно заменять интерфейсы с vector, но есть немало интерфейсов, которые готовы работать с любой последовательностью, а не только непрерывной. В результате применимость span является прямо пропорциональной количеству интерфейсов, готовых довольствоваться ограничением непрерываности. В таких случаях span — это отличное решение, но универсальностью string_view span не обладает.
Наконец, mdspan вообще выбивается из этой тройки, потому что его применимость ограничена наличием задачи, а задач работы с многомерными массивами куда меньше, чем со строками и одномерными непрерывными массивами. Всё же я считаю mdspan наиболее важным пополнением стандартной библиотеки, потому что помимо унификации, которую дают и string_view со span, он добавляет в язык нечто новое, чего раньше мы сделать не могли.
Сказав всё это, хочется добавить, что все три типа должны были быть в C++ ещё в 1998 году. Тем более, что все они являются небезопасными, а сегодня это (справедливо!) не в моде. Но лучше поздно, чем никогда.
Я встречал измерения, которые показывали, что string_view несколько медленнее const char* const, но разница там была незначительная, и я сам таких измерений не проводил.
Любой запрет в C++, который не исходит из стандарта, является «мягким». goto в целом запрещён в C++-коде, но это не значит, что нет мест, где он может по делу использоваться. Запрет касается общих случаев и в основном предназначен для того, чтобы, если этот запрет обходится, программист представлял весомое обоснование.
Работы над std::mdarray идут, если интересно, следите за этими предложениями: P1684, P3308.