为什么 std::array 需要将 size 作为模板参数而不是构造函数参数?

Why does std::array require the size as a template parameter and not as a constructor parameter?

提问人:ruff 提问时间:1/26/2023 最后编辑:DailyLearnerruff 更新时间:1/27/2023 访问量:549

问:

我发现这有很多设计问题,特别是在传递给函数时。基本上,当你初始化 std::array 时,它会接受两个模板参数,和 .但是,当您创建需要 和 的函数时,我们不知道大小,因此我们也需要为函数创建模板参数。std::array<><class Tsize_t size>std::array

template <size_t params_size> auto func(std::array<int, params_size> arr);

为什么不能在构造函数中输入大小?(即):std::array

auto array = std::array<int>(10);

然后,这些函数看起来不那么激进,并且不需要模板参数,因此:

auto func (std::array<int> arr);

我只想知道 的设计选择,以及为什么这样设计。std::array

这不是由于错误而产生的问题,而是为什么以这种方式设计的问题。std::array<>

C++ C++11 标准 STDarray

评论

16赞 Nathan Pierson 1/26/2023
如果它按照您想要的方式设计,它将是一个.不同的类型,具有不同的权衡。编写一堆标准库函数来采用迭代器对(或者,在 C++20 中,迭代器和哨兵)而不是直接使用容器是有原因的。std::vector
3赞 Nicol Bolas 1/26/2023
模板的“侵略性”是什么?
4赞 Pepijn Kramer 1/26/2023
这都是关于不要为你不使用的东西付费。如果不想为动态内存分配付费,则必须具有可以执行静态内存分配的类型。这就是 std::array。它还允许您将数组用作类。因此,有了这个,您可以从函数中返回它们,通过常量引用传递它们。而且在传递数组时,您永远不会丢失数组的大小(“C”样式数组的指针衰减)。
0赞 alfC 1/26/2023
为了使它看起来不那么咄咄逼人,只需将 替换 .params_size -> N

答:

12赞 Henrique Bucher 1/26/2023 #1

std::array<T,N> var旨在更好地替代 C 样式数组。T var[N]

此对象的内存空间是在本地创建的,即在局部变量的堆栈上或在结构本身内部(定义为成员时)创建。

std::vector<T>相反,始终在堆中分配其元素的内存。

因此,由于在本地分配,它不能具有可变大小,因为需要在编译时保留该空间。 另一方面,由于其内存是无限的,因此具有重新分配和调整大小的能力。std::arraystd::vector

因此,在性能方面的最大优势在于,它消除了为其灵活性付出代价的间接水平。std::arraystd::vector

例如:

#include <cstdint>
#include <iostream>
#include <vector>
#include <array>

int main() {
    int a;
    char b[10];
    std::vector<char> c(10);
    std::array<char,10> d;
    struct E {
        std::array<char,10> e1;
        std::vector<char> e2{10};
    };
    E e;

    printf( "Stack address:   %p\n", __builtin_frame_address(0));
    printf( "Address of a:    %p\n", &a );
    printf( "Address of b:    %p\n", b );
    printf( "Address of b[0]: %p\n", &b[0] );
    printf( "Address of c:    %p\n", &c );
    printf( "Address of c[0]: %p\n", &c[0] );
    printf( "Address of d:    %p\n", &d );
    printf( "Address of d[0]: %p\n", &d[0] );
    printf( "Address of e:    %p\n", &e );
    printf( "Address of e1:   %p\n", &e.e1 );
    printf( "Address of e1[0]:%p\n", &e.e1[0] );
    printf( "Address of e2:   %p\n", &e.e2);
    printf( "Address of e2[0]:%p\n", &e.e2[0] );
}

生产

Program stdout
Stack address:   0x7fffeb115ed0
Address of a:    0x7fffeb115eb0
Address of b:    0x7fffeb115ea6
Address of b[0]: 0x7fffeb115ea6
Address of c:    0x7fffeb115e80
Address of c[0]: 0x1cad2b0
Address of d:    0x7fffeb115e76
Address of d[0]: 0x7fffeb115e76
Address of e:    0x7fffeb115e40
Address of e1:   0x7fffeb115e40
Address of e1[0]:0x7fffeb115e40
Address of e2:   0x7fffeb115e50
Address of e2[0]:0x1cad2d0

Godbolt:https://godbolt.org/z/75s47T56f

0赞 Obsidian 1/26/2023 #2

C++11的主要目的是成为C样式数组的体面替代品,特别是当它们被声明和用 .std::array<>[]newdelete[]

这里的主要目标是获得一个官方的托管对象,该对象用作数组,同时将所有可能的内容作为常量表达式进行维护。

常规数组的主要问题是,由于它们实际上不是对象,因此无法从中派生类(迫使您实现迭代器),并且当您复制将它们用作对象属性的类时会很痛苦。

由于 和 返回指针,因此每次都需要实现一个复制构造函数,该构造函数将声明另一个数组,它们复制其内容,或者在其上维护自己的动态引用计数器。newdeletedelete[]

从这个角度来看,是声明纯静态数组的好方法,这些数组将由语言本身管理。std::array<>

评论

6赞 Avi Berger 1/26/2023
不。那就是 std::vector。std::array 是未动态分配的原始/本机数组的包装器。也就是说,new 用于创建数组,但它具有固定的编译时间大小。
0赞 Obsidian 1/26/2023
这就是我解释的。请再读一遍。该评论的目的是说明为什么以这种方式引入,以及与先前标准版本中已经存在的向量相比,它们的用途是什么。std::array<>
0赞 Avi Berger 1/27/2023
“std::array<> . . .替换 C 样式数组 [],尤其是当它们用 new 声明时“是编译时已知的固定大小。new/delete 提供动态分配的数组,其大小在运行时确定,而不是编译时确定(并且只能通过指针管理,而不能直接管理。 不能用作运行时大小的数组的替代品;这就是 std::vector 的工作。此外,作为类成员的本机/原始数组复制也很好。(指向动态分配数组的指针是另一回事,但数组不是指针。std::array<>std::array<>
3赞 Dúthomhas 1/26/2023 #3

真的,这不是一个答案,因为我曾经出于和你一样的原因鄙视——任何具有 Monadic 品质的东西都不是好设计 (IMNSHO)。std::array<>

幸运的是,C++20 有解决方案:动态 .std::span<>

#include <array>
#include <iostream>
#include <span>

namespace detail
{
  void print( const std::span<const int> & xs )
  {
    for (size_t n = 0;  n < xs.size();  n++)
      std::cout << xs[n] << " ";
  }
}

void print( const std::span<const int> & xs )
{
  std::cout << "{ ";
  detail::print( xs );
  std::cout << "}\n";
}

void add( const std::span<int> & xs, int n )
{
  for (int & x : xs)
    x += n;
}

int main()
{
  std::array<int,5> xs { 1, 2, 4, 6, 10 };
  add( xs, 1 );
  print( xs );
}

请注意,它本身在所有情况下都是可修改的,但元素本身是可修改的,除非它们也被标记。这正是数组的样子。spanconstconst

std::span是一个 C++20 对象。我知道 MS 和其他人的库的旧版本。array_view

TL的;dr
用于声明数组对象。用动态传递它。
std::arraystd::span


std::array 与 C 数组

其用例实际上非常狭窄:将固定大小的数组封装为一流的容器对象(一个可以复制的对象,而不仅仅是引用)。std::array

乍一看,这似乎比标准的 C 型数组没有太大的改进:

typedef int myarray[10];             // (1)
using myarray = std::array<int,10>;  // (2)

void f( myarray a );

但事实确实如此!区别在于实际得到什么:f()

  1. 对于 C 样式的数组,参数只是一个指针 — 对调用方数据的引用(您可以修改!您知道引用数组 () 的大小,但是即使使用通常的 C 数组大小惯用语(,因为指针的大小),编写代码来获得该大小也不是直接的。10sizeof(myarray)/sizeof(a[0])sizeof(a)
  2. 对于 ,参数值是调用方数据的实际本地副本。如果您希望能够修改调用方的数据,则需要明确将形式参数声明为引用类型 () 或只是为了避免昂贵的副本 ()。这与其他 C++ 对象的传递方式一致。虽然大小仍然是 ,您的代码可以使用通常的 C++ 容器习惯语查询数组的大小:!std::arraymyarray & aconst myarray & a10a.size()

C 克服这个问题的通常方法是将有关数组大小的信息混淆调用站点和正式参数列表,以免丢失。

int f( int array[], size_t n )   // traditional C
{
  printf( "There are %zu elements.\n", n );
  recurse with f( array, n );
}

int main(void)
{
  int my_array[10];
  f( my_array, ARRAY_SIZE(my_array) );

路更干净。std::array

int f( std::array<int,10> & array )   // C++
{
  std::cout << "There are " << array.size() << " elements.\n";
  recurse with f( array );
}

int main()
{
  std::array<int,10> my_array;
  f( my_array );

但是,虽然更干净,但它的灵活性明显不如C数组,这仅仅是因为它的长度是固定的。例如,调用方不能将 a 传递给函数。std::array<int,12>

我将向你推荐此处的其他好答案,以在处理阵列数据时考虑更多关于容器选择的信息。

评论

1赞 Henrique Bucher 1/26/2023
这是一本好书。我还没有使用 std::span,但一旦 c++20 成为主流,我就会使用。
0赞 alfC 1/26/2023
有时,需要制作副本,尤其是通过函数参数。引入一种新类型不是 IMO 的解决方案,还搞砸了语义、破坏恒常性等。啊,也不要在任何不是函数参数的地方使用。这对我的口味来说太有问题了。我认为它只解决了连续内存具有稳定 ABI 的问题,但对泛型编程不利。std::spanstd::span
0赞 alfC 1/26/2023
我很好奇“任何具有Monadic品质的东西都不是好的设计”。怎么样 ?就算是这样,为什么会是糟糕的设计呢?std::array
0赞 Dúthomhas 1/26/2023
@alfC 在像 Haskell 这样的纯语言中,monad 用于管理 I/O 之类的东西,而不会引入副作用。问题在于,它们会污染整个调用链中的所有内容:将杂质从副作用转移到程序的结构中。 做同样的事情,将笨拙的语法和干净的解决方案交换为干净的语法和笨拙/臃肿的解决方案。根本问题在于,数组从来都不是一等公民,而它们本来很容易成为一等公民。但是在游戏的后期,技术障碍太奇怪了,太神秘了,无法在C和C++中解决。std::array
0赞 alfC 1/26/2023
我看不出相似之处,因为我对 Haskell 或单子的了解不够,无法判断。但是我看不出有什么比返回纯值更实用的了,而 std::array(给定的 T 和 N)是一个纯值。你不能返回 T[N] 或 std::span,即使你可以,这两个也不是纯值,而是对外部值的引用。
1赞 alfC 1/26/2023 #4

如果你有一个问题,你认为是一个解决方案,现在你会遇到两个问题。std::arraystd::span

更严重的是,在不知道什么样的概念操作的情况下,很难说出什么是正确的选择。func

首先,如果你想或可以利用在编译时知道大小,没有什么比你试图避免的更酷的了。

template<std::size_t N> 
void func(std::array<int, N> arr);   // add & or && or const& if appropiate

想象一下,在编译时知道大小可以让你编译器做各种各样的技巧,比如完全展开循环或在编译时验证逻辑(例如,如果你知道大小必须小于或大于常量)。 或者最酷的技巧,不需要为内部的任何辅助操作分配内存(因为您先验地知道问题的规模)。func

如果需要动态数组,请使用(并传递).std::vector

void func(std::vector<int> dynarr);   // add & or && or const& if appropiate

但是,您强制调用方用作容器。std::vector

如果你想要一个固定的数组,它可以与所有东西一起工作,

template<class FixedArray>
void func(FixedArray dynarr);   // add & or && or const& if appropiate

问问自己,你的功能有多具体,以至于你真的想让它适用于任何大小,但不能与? 为什么特别 s 偶数?std::arraystd::vectorint

template<class ArithmeticRange>
void func(ArithmeticRange dynarr);   // add & or && or const& if appropiate

评论

0赞 Caleth 1/26/2023
我会有第一个例子是 ,然后继续这个版本void func(std::array<int, 3> arr);template <size_t N>
0赞 alfC 1/26/2023
@Caleth,我不明白你的评论,你在解释OP逻辑吗?
1赞 Caleth 1/26/2023
不,我是说 OP 错过了非常相关的情况,您从一开始就知道大小
0赞 alfC 1/26/2023
@Caleth,是的,我意识到“知道”对于来自解释型语言的人来说是一个棘手的概念,尤其是在过渡到可以进行元编程的语言时。人们可以在运行时“知道”事物,但不能在编译时或编码时“知道”事物,或者可以在运行时和编译时“知道”事物,但不能在编码时“知道”事物,等等。
0赞 Yakk - Adam Nevraumont 1/27/2023
按值动态分配缓冲区似乎是迈出的一大步。上面的所有其他解决方案都需要模板代码生成,这些模板在小项目以外的任何项目中都存在代码大小、编译时间和依赖性问题。更重要的是,整个算法都是模板化的,而不仅仅是一小块,这可能会使问题变得更糟。如果编译器愿意,他们可以内联诸如“数组大小”之类的内容;模板实际上迫使编译器这样做。
1赞 Yakk - Adam Nevraumont 1/27/2023 #5

C++ 中有一些连续的容器和范围。它们有不同的目的。还有一些技术可以传递它们。std

我会尽量做到详尽无遗。

std::array<int, 7>

这是 7 秒的缓冲区。它们存储在对象本身中。放置某个位置就是在该位置放置足够的存储空间(加上出于对齐原因的可能填充,但那是在缓冲区的末尾)。intarray7int

在编译时,当您确切地知道某件事有多大或需要知道时,您可以使用它。

std::vector<int>

此对象拥有 S 缓冲区的所有权。保存这些 s 的内存是动态分配的,并且可以在运行时更改。对象本身的大小通常为 3 个指针。它有一些增长策略,可以避免在你一次添加 1 个元素时做 N^2 工作。intint

这个对象可以有效地移动 -- 如果旧对象被标记为(通过或其他方式)可以安全地窃取状态,它将窃取缓冲区。std::move

std::span<int>

这表示外部拥有的 s 序列,可能存储在 a 中或由 a 拥有,或存储在其他地方。它知道它在内存中的哪个位置开始,何时结束。intstd::arraystd::vector

与上述两个不同,它不是一个容器,而是一个范围或内容视图。因此,您不能将跨度分配给彼此(语义令人困惑),并且您有责任确保源缓冲区持续“足够长的时间”,以便在它消失后不会使用它。

span通常用作函数参数。在你的例子中,它可能解决了你的大部分问题 - 它允许你将不同大小的数组传递给一个函数,在该函数中你可以读取或写入值。

span遵循指针语义。这意味着就像一个 -- 指针是 ,但指向的东西不是!您可以自由修改 中的元素。相比之下,就像一个 -- 指针不是 const,但指向的东西是。您可以自由更改 span 所指的元素范围,但不允许修改元素本身。const std::span<int>int*constconstconst std::span<int>std::span<const int>int const*std::span<const int>

最后一种技术是或模板。在这里,我们在标头(或等效项)中实现函数的主体,并使类型不受约束(或受概念约束)。auto

template<std::size_t N>
int total0( std::array<int, N> const& elems ) {
  int r = 0;
  for (int e:elems) r+=e;
  return r;
}

int total1( std::vector<int> const& elems ) {
  int r = 0;
  for (int e:elems) r+=e;
  return r;
}

int total2( std::span<int const> elems ) {
  int r = 0;
  for (int e:elems) r+=e;
  return r;
}

int total3( auto const& elems ) {
  int r = 0;
  for (int e:elems) r+=e;
  return r;
}

template<class Ints>
int total4( Ints const& elems ) {
  int r = 0;
  for (int e:elems) r+=e;
  return r;
}

请注意,它们都具有相同的实现。

total3并且是相同的;您需要一个更现代的编译器来使用语法。total4total3

total1并允许您将实现拆分为远离头文件的 CPP 文件。此外,不会针对不同的参数进行代码生成。total2

total0,并且都会导致根据参数的类型生成不同的代码。这可能会导致二进制膨胀问题,尤其是在主体比显示的更复杂的情况下,并导致大型项目中的构建时间问题。total3total4

total1不能直接与 a 一起使用。您可以在使用代码之前将内容复制到动态向量。std::arraytotal1({arr.begin(), arr.end()})

最后,请注意,这是最接近 C 的方式。从本质上讲,Span 是一个指向第一个和长度的指针对,并用实用程序代码包裹它。span<int>arr[], size