我正在学习C ++,因此我决定对std::vector进行简单的克隆。

关注点:


我见过人们在类外定义方法,而仅在类内定义它们的原型。我也应该这样做吗?
我是需要内存管理的低级语言的新手,所以可能会有泄漏。
我可以进行性能优化吗?
一般样式好吗?

我的代码:

#include <iostream>
#include <algorithm>

template <class element>
class Vector {
  private:
  int len, cap;
  element* arr;

  void allocate_to_len() {
    int new_cap = min(cap, 1); // size-0 vectors wont loop infinitely
    while (len > new_cap) {
      new_cap *= 2;
    }
    resize(new_cap);
  }

  public:
  Vector() : Vector(10) {}

  Vector(int size) {
    len = 0;
    cap = size;
    arr = new element[size];
  }

  Vector(const Vector<element>& v) {
    len = v.length();
    cap = v.capac();
    arr = new element[cap];
    for (int i = 0; i < len; i++) {
      arr[i] = v.get_value(i);
    }
  }

  ~Vector() {
    delete[] arr;
  }

  element& operator[](int index) {
    if (index < 0 || index >= len) {
      throw "Index out of range";
    }
    return arr[index];
  }

  int length() const {
    return len;
  }

  int capac() const {
    return cap;
  }

  element get_value(int index) const {
    if (index < 0 || index >= len) {
      throw "Index out of range";
    }
    return arr[index];
  }

  void resize(int size) {
    cap = size;
    element* newarr = new element[size];
    len = std::min(len, cap);
    for (int i=0; i < len; i++) {
      newarr[i] = arr[i];
    }
    arr = newarr;
  }

  void append(element item) {
    len += 1;
    allocate_to_len();
    arr[len - 1] = item;
  }

  void extend(Vector<element> d) {
    int length = d.length();
    int old_len = len;
    len += length;
    allocate_to_len();
    for (int i=0; i < length; i++) {
      arr[i + old_len] = d[i];
    }
  }
};

int main() {
  // Tests:

  Vector<int> d;

  d.append(1);
  d.append(2);
  d.append(3);

  for (int i=0; i<d.length(); i++) {
    std::cout << d[i] << "\n";
  }
  std::cout << "\n";

  Vector<int> d2;

  d2.append(4);
  d2.append(5);
  d2.append(6);

  d.extend(d2);

  for (int i = 0; i < d.length(); i++) {
    std::cout << d[i] << "\n";
  }
  std::cout << "\n";

  d.resize(4);

  for (int i = 0; i < d.length(); i++) {
    std::cout << d[i] << "\n";
  }
}


#1 楼

首先,让我们回答您的问题:


是的,也许吧。有些人以一种方式这样做,而另一些则以其他方式。就个人而言,我更喜欢将声明和定义拆分开,这样我可以在顶部概述我的课程。但是,有些人认为此方法非常冗长(并且可能会很冗长,尤其是对于多层模板),因此任何一种方法都可以。
是的,resize()中至少存在一次内存泄漏。您在此处分配内存,但不释放旧内存的事实证明了这一点。该方法返回后,内部指针指向新分配的数组,而旧的数组则被遗弃并且在堆深处不可访问。若要更正此问题,只需在分配delete[]之前先对旧数组进行初始化。
可能,但我不想在这里使用


,您似乎没有测量了代码的任何性能特征,并且只要您不进行基准测试,就不会在意性能
,这意味着您实际上并不在乎性能(如果这样做,您可能会它),并且
您的代码中没有任何东西对我大喊“我真的很慢”,因此无论如何也没有理由进行优化。你所说的“一般风格”。如果您的意思是正确的缩进,变量名等,我会说是的。如果您是说要使用STL和标准算法,那我仍然有工作要做。

仔细观察

您没有要求进行此类审查,因此,如果您不感兴趣,请随意忽略答案的这一部分。

虽然您的总体代码给我留下了很好的印象,但有些东西看起来并不感觉正确,在我看来,应该再考虑一两个:


尽管您说容器是arr = newarr的简单克隆,但在分配和管理元素时,其行为与标准容器有很大不同。问题在于,如果没有(默认-)构造元素,您的容器将无法保留任何内存。但是,如果看一下std::vector,您会发现它提供了std::vector接口,可让您提前分配内存而无需实际构造任何元素。但是,这种行为在代码中很难实现,因此,如果您仍在掌握C ++的过程中,尤其是在您感到不安全的情况下,则可能要坚持使用当前版本
尽管我倾向于一遍又一遍地迭代这一点,但int不是索引和迭代变量的正确类型。首先,它是一个带符号的类型,它允许它采用负值(这是您的取消引用方法所支持的事实)。其次,在现代x86_64系统(恰好是大多数台式机用户正在运行的系统)上,reserve可能仅为32位数据类型,这意味着您将自己的向量限制为大约4 GB的内存,并且正在运行高指数溢出风险。取而代之的是,C ++提供了int类型,顾名思义,它非常适合于大小和索引变量。它是无符号的,并且在典型的64位平台上,通常大小为64位。
将异常强加给用户是不明智的做法。造成这种情况的原因之一是异常发生时的速度确实很慢。另一个原因是,并非所有系统都容易获得异常,尤其是嵌入式开发部门。我对您的建议是遵循std::size_t设计模式:取消选中std::vector,对越界访问具有未定义的行为,并提供一个进行边界检查并抛出越界的operator[]方法。这也可能导致性能提高,因为如果已知索引在边界之内,就无法避免索引验证。
正如我在问题四的答案中已经提到的那样,使用标准算法还有一定的潜力,特别是在将数据从一个阵列复制/移动到另一个阵列的地方,尤其是at()std::copy。通常,如果您有一个简单的for循环,可对一系列元素进行处理,而在其主体上没有什么复杂的事情,则可以用对std::movealgorithm的函数调用来代替该循环。
避免幻数。例如,在numeric中,10是什么意思?为什么是10?每当您发现自己写了一个魔术数字时,就将其重构为常数并为其命名。在这种情况下,Vector() : Vector(10) {}或类似的东西将是合适的。
尽管这可能部分是因为这是一个代码审查问题,但您的类提供的接口却很少。我发现特别缺乏的是对迭代器的支持,即vector_default_sizebegin()。对于向量,根本就不难实现,因为每个原始指针都会自动满足end()的要求。
说到原始指针,您的类当前的实现方式非常适合RandomAccessIterator的使用。这将带来额外的好处,您不必再担心移动构造函数和析构函数,因为这些将由唯一的指针自动处理。通常,您应该尽量避免使用原始指针,因为它们没有所有权语义并且容易被误用。


部分答案写在Martin York发布之前他的答案。因此,可能存在一些重叠的点。如果您发现这些观点中的任何一项都具有吸引力,请感谢马丁·约克,因为他比我快。

评论


\ $ \ begingroup \ $
第2点:我完全不同意int不是索引和迭代变量的正确类型。我认为int是更好的数据类型正是因为它可以是负数。使用无符号类型的麻烦在于,它们会自动转换负值而无需进行附加操作(通常会产生非常大的正值)。通过允许负值,您实际上可以检查它们并更轻松地发现错误。
\ $ \ endgroup \ $
–马丁·约克
18年2月13日在0:50



\ $ \ begingroup \ $
第7点:我不同意,但不是那么多。我认为智能指针和容器正在尝试实现同一目标。虽然我可以看到使用智能指针的观点,因为它简化了容器,并且对于非常简单的容器也适用,但是我相信,当您深入了解实现容器的细节时,这些优点会随着容器管理容器及其中所有元素的内存。
\ $ \ endgroup \ $
–马丁·约克
18年2月13日,0:57

\ $ \ begingroup \ $
您的答案比我的答案更为优雅和精致。这就是为什么我能够迅速做出回应的原因。
\ $ \ endgroup \ $
–马丁·约克
18年2月13日在1:20

\ $ \ begingroup \ $
@MartinYork感谢您的好评!要表达您的观点:第二点:我认为这里的奖牌有两个方面。即使这样,由于其大小限制,我也不认为int是索引和大小的正确选择。如果您更喜欢带符号变量,则std :: ptrdiff_t可能是最佳选择,不是吗?第7点:总的来说,我同意你的意思。也许我没有足够清楚地表达这一点,但是我的意思是当前非常简单的容器实现很适合智能指针的使用,因为它基本上只是管理动态大小的数组。
\ $ \ endgroup \ $
–本·斯特凡(Ben Steffan)
18年2月13日在8:30

\ $ \ begingroup \ $
第4点:不幸的是,在范围方面没有与std :: move_if_noexcept等效的选项,这确实很烦人。管理好自己的条件就是自找麻烦。
\ $ \ endgroup \ $
– Matthieu M.
18年2月13日在15:30

#2 楼

问题

您的向量假设要存储的类型具有默认构造函数。

arr = new element[size];


这对于效率高昂的创建类的效率确实很低您不会使用所有的size成员。您希望设计类,以便仅在首次添加元素时才构造矢量中的对象。

错误

您没有实现三个规则。

默认情况下,编译器会为您创建三个方法。复制构造函数/复制分配/析构函数。您尚未实现“复制分配”运算符。这意味着如果有分配,您将遇到一个错误(由于浅表复制问题)。

设计问题

通过引用返回值: />
element get_value(int index) const;


这里返回元素的副本。这可能非常昂贵。因此,如果您按引用返回(在这种情况下为const引用),那就太好了。这样,您无需先实际复制数据就可以读取数据。

设计问题

检查索引范围并不能保证索引的正确性。即将到来。您应该设计您的班级,以允许专家用户不受限制地访问(即,不进行检查),但也为仍在测试和使用未经验证的输入的人员提供了经过检查的界面。

element& operator[](int index) {
  if (index < 0 || index >= len) {
    throw "Index out of range";
  }
  return arr[index];
}


这是std::vector使用的设计。

T& std::vector::operator[](index);   // Does not check index
T& std::vector::at(index);           // Does check that index is valid


所以在这种情况下:

for(int loop = 0; loop < v.length(); ++loop) {
    std::cout << v[loop];
}


在这种非常标准的情况下,我知道循环始终在边界之内。因此,不需要检查边界。这正是std::vector<>具有两个用于访问元素的接口的原因。
常量问题

许多接口都通过const reference传递对象。这使您可以在不复制对象的情况下传递对象,也无需给予对象写权限(这称为const正确性)。

因此许多访问接口也具有const版本。 br />
T const& std::vector::operator[](index) const;   // Does not check index
T const& std::vector::at(index)         const;   // Does check that index is valid


内存泄漏

  void resize(int size) {
    cap = size;
    element* newarr = new element[size];
    len = std::min(len, cap);
    for (int i=0; i < len; i++) {
      newarr[i] = arr[i];
    }


此处修复

    // before you can overwrite `arr`
    // You need to deallocate the array and all its members.
    std::swap(arr, newarr);  // So swap the values.
    delete [] newarr;        // Now you can delete the old array.
  }


注意事项

我写了一系列有关编写向量的文章。

向量-资源管理向量-复制和交换向量-调整大小向量-简单优化向量-其他东西

评论


\ $ \ begingroup \ $
关于边界检查:我确实喜欢在operator []中使用assert,以帮助更早地发现错误...
\ $ \ endgroup \ $
– Matthieu M.
18年2月13日在14:15

\ $ \ begingroup \ $
“在界面中进行检查非常昂贵”。那么,您认为标准用例的性能开销是多少呢? 20%? 15%?或根本不超过0.1%?在什么级别的性能开销上值得引入安全性和正确性问题呢?
\ $ \ endgroup \ $
– Voo
18年2月13日在14:20



\ $ \ begingroup \ $
@voo如果我们看一下vector(std :: vecter)的标准实现。您将看到有两个界面。一个选中的接口和一个未选中的接口。造成差异的原因是我们不想限制选择。对于那些已经进行了适当验证的人,我们希望提供最佳代码,对于那些需要进行运行时验证的人,则提供了该选项。做一个快速测试。 gist.github.com/anonymous/03af79051d59bf3dce3ca366c1d66493我看到了0.35%的差异(显然是简单的测试)。
\ $ \ endgroup \ $
–马丁·约克
18-2-13在16:18



\ $ \ begingroup \ $
@ Nik-Lz:1)NRVO将副本数减少为一,但并没有消除它们。通过const引用返回将消除所有副本。对于容器,如果不需要,为什么要完全有副本。
\ $ \ endgroup \ $
–马丁·约克
18-10-22在10:20

\ $ \ begingroup \ $
@ Nik-Lz 2 2)当然可以进行假设。但是没有必要做这个。我是否必须有一个默认的(零参数构造函数)构造函数才能将其放入std :: vector中。如果不需要,为什么要用这种方式限制班级。
\ $ \ endgroup \ $
–马丁·约克
18-10-22在10:23

#3 楼

我还没有看到其他答案足以说明如何管理向量大小。

设计问题

您声明了int len, cap;,但至少您一直在使用int。通常使用size_t。 (请参阅Ben Steffan的答案2)

实现错误

allocate_to_len有一个错误。具体来说,如果len > INT_MAX / 2,则溢出cap,这将导致int的行为不确定,而size_t则导致溢出。 vector的标准实现提供了一种方法max_size,该方法返回最大的实现定义容量。保留或增大该大小的向量将引发length_error。类似,在append中,您可能会溢出len

命名

capac应该被称为capacityget_valueatlengthsizeappend在C ++中称为push_back。这引出我的最后一个观点。

遵循容器概念

如果您希望向量在其他情况下可重用,则应遵循容器概念。这将允许其他库,例如<algorithm>使用您的容器。

其中一部分是将void extend(Vector<element> d)更改为template <class InputIterator> void insert (iterator position, InputIterator first, InputIterator last);(还应注意,应通过const引用接受此参数),并为容器实现iterator

评论


\ $ \ begingroup \ $
如在对其他答案的评论中所提到的,在此可以使用签名类型,实际上是当今委员会的共识。他们承认使用无符号类型是一个错误,并且今后,该标准可能会包括用于索引和大小的带符号类型std :: index_t。
\ $ \ endgroup \ $
–康拉德·鲁道夫(Konrad Rudolph)
18年2月13日在12:35



#4 楼

我只想指出另外2件事,但我似乎还没有提及。

Append每次都会调整向量的大小

我看到您可以区分容量和长度。其背后的总体思路是,向量将始终具有一定的可用空间,而不必每次都重新分配内存,复制元素和取消分配内存。在append方法中,无论是否需要更多内存,都调用allocate_to_len()。仅当在增加len后,allocate_to_len()时,才应调用len >= cap。 >计算新容量时,请将当前容量加倍,直到大于当前长度。如果您遵循我的上述建议,那么容量可以保证至少是长度,如果不是更大的话。将其加倍一次就足够了,因此可以删除循环。此外,您还可以简单地将新容量计算为长度的两倍。我相信这是std::vector的标准分配行为

void append(...) {
    len += 1;
    if (len >= cap)
        allocate_to_len();
    ...
}


足够了,除非确保您没有超出另一个答案所指出的最大长度。

总体上很棒!我只是在挑剔。重写标准容器和算法非常有用,这也是我第一次学习时所做的。

#5 楼

异常安全性

您的容器也不是异常安全性。

用C ++编写容器的第一个困难是在一切顺利的情况下处理内存。其他答案已经解决了这一点,因此我不再重复。

用C ++编写容器的第二个困难是在用户对抗时处理内存。在C ++中,可能会引发异常:


在默认构造函数中,
在副本构造函数中,
在副本赋值运算符中,
在移动构造函数中,
在移动分配运算符中,
在析构函数中...

每次调用用户逻辑时,它都可能抛出。

您必须决定如何处理此行为:


不计入例外情况是不可行的,您可以通过标记您的方法来解决问题noexcept 1尽管您的用户可能没有感激,
至少您不应该泄漏(基本例外保证);如果您使用的是new / delete内存处理,则更有可能失败,
应始终尝试提供“强异常保证”,这意味着如果用户定义的操作抛出了容器,则将保持不变。

请注意,强异常保证有时有时需要昂贵的操作(复制和交换)。在这种情况下,最好坚持使用“基本异常保证”并记录其行为。

1如果异常尝试“离开” noexcept方法,则程序会突然终止。这比破坏内存更好,但仍然很烦人。


考虑到这一点,请考虑以下内容:

template <typename T>
class Vector {
public:
    Vector() = default;

    //  WARNING:
    //  The COPY CONSTRUCTOR, COPY ASSIGNMENT OPERATOR and DESTRUCTOR
    //  are missing and should be defined. Correctly.

    void push_back(T&& e);
    void push_back(T const& e);

private:
    using Raw = typename std::aligned_storage<sizeof(T), alignof(T)>::type;

    void grow_by(std::ptrdiff_t n);

    T* raw();

    std::ptrdiff_t mCapacity = 0;
    std::ptrdiff_t mLength = 0;
    std::unique_ptr<Raw[]> mStorage;
};


template <typename T>
void Vector<T>::push_back(T&& e) {
    this->grow_by(1);

    new (this->raw() + mLength) T{std::move(e)};
    ++mLength;
}

template <typename T>
void Vector<T>::push_back(T const& e) {
    this->grow_by(1);

    new (this->raw() + mLength) T{e};
    ++mLength;
}

template <typename T>
void Vector<T>::grow_by(std::ptrdiff_t n) {
    if (mLength + n <= mCapacity) { return; }

    //  Prepare new storage
    auto newCapacity = std::max(mCapacity, 1);
    do {
        newCapacity *= 2;
    } while (mLength + n > newCapacity);

    Vector next;
    next.mCapacity = newCapacity;
    next.mStorage.reset(new Raw[newCapacity]);

    //  Transfer objects:
    //  1. if T's move constructor is noexcept, use it!
    //  2. otherwise, if T has a copy constructor, use it!
    //  3. otherwise, use T's move constructor.
    //
    //  Cases 1 and 2 offer the Strong Exception Guarantee; case 3
    //  unfortunately offers only the Basic Exception Guarantee.
    for (auto& t : *this) {
        next.push_back(std::move_if_noexcept(t));
    }

    //  "Commit" the change.
    std::swap(*this, next);
}

template <typename T>
T* Vector<T>::raw() { return reinterpret_cast<T*>(mStorage.get()); }


这里要注意两个std功能:使用std::aligned_storage<>::type可以使我们以很少的代码提供最佳的异常保证。

此外,请注意Tstd::move_if_noexcept的组织方式(如果可能的话)可以提供强异常保证:


在不修改对象的情况下执行可能抛出的操作,
完成工作。

对于grow_by,您可以看到长度仅在例如,对象的构造成功。

对于push_back,请注意,如果在循环中引发异常,它如何重用当前的push_back类来处理已复制/移动的元素的破坏。 。


我不会撒谎,向量中的异常安全性很难。

在向量中间插入一系列元素是噩梦,移动构造函数/移动赋值运算符可能会抛出。

评论


\ $ \ begingroup \ $
我会改变:充其量,您应该提供给您应该始终尝试提供(在可接受的情况下),
\ $ \ endgroup \ $
–马丁·约克
18-2-14在17:07



\ $ \ begingroup \ $
注意溢出:newCapacity * = 2;。这似乎是一个错误mLength + n> newCapacity
\ $ \ endgroup \ $
–马丁·约克
18年2月14日在17:14

\ $ \ begingroup \ $
@MartinYork:确实存在溢出的风险,尽管这不是示例的目的,但我不想在这里处理它。在一个完整的解决方案中,我建议不要使用循环。这还不错(O(log N)),但是有更有效的方法可以使用内在函数找到下一个更大的2的幂(__builtin_clz让您大饱眼福)。
\ $ \ endgroup \ $
– Matthieu M.
18年2月14日在18:22

\ $ \ begingroup \ $
@MartinYork:我认为mLength + n> newCapacity是正确的,有什么特别需要关注的吗?
\ $ \ endgroup \ $
– Matthieu M.
18年2月14日在18:29