在我的b树作业的解决方案中,我做了很大的努力,以确保我遵循了c++编程的最佳实践。学生们经常忽略的一个最佳实践是需要仔细的内存管理。c++内存管理的基本规则是
如果您使用new创建它,则必须稍后删除它。
大多数初级程序员通常会忽略这条规则,但有经验的程序员会竭尽全力确保他们始终忠实地遵循这条规则。
在这些课堂笔记中,我将向您展示遵循此规则的两种方法。第一种方法依赖于对内存管理的仔细考虑,而第二种方法使用c++ 11中出现的一些现代工具来帮助自动执行内存管理任务。
在传统的方法中,我们非常注意使用new创建对象的情况。在使用new创建对象的任何情况下,都需要确保对象最终被删除。
确保内容被删除的第一步是提供带有析构函数的任何保存指向对象的指针的类。析构函数显然是删除所属对象所指向的任何对象的地方。为此,我在最初的解决方案中为BTree和BTreeNode类创建了析构函数:
模板<typename T, int M> BTreeNode<T, M>::~BTreeNode() {if (!leaf) {for (int n = 0;n <= keyTally;(N ++)删除指针[N];}}模板<typename T, int M> BTree<T, M>::~BTree() {if (root != nullptr)删除根;}
这样我们就可以删除赢博体育用new创建的内容。不幸的是,它并不能涵盖赢博体育情况。大多数数据结构还具有删除项的方法,删除项通常会导致从数据结构中删除对象。BTree类也不例外。大多数情况下,当我们从b树中删除一个键时,我们最终会从节点中删除一个键,但节点本身并没有从b树中删除。在极少数情况下,我们会发现自己最终从b树中删除了一个节点:当这种情况发生时,我们必须小心地删除节点。在b树的生命周期中,涉及到删除节点的一个事件是合并算法。在这个算法中,我们将两个节点合并为一个节点,在这个过程中,两个节点中的一个必须被破坏。以下是相关代码:
模板<typename T, int M> void BTree<T, M>::巩固(const BTreeLink<T, M>&左,const BTreeLink<T, M>&右){BTreeNode<T, M> *parent = left.parent;BTreeNode<T, M> *leftChild = left.ptr();BTreeNode<T, M> *rightChild = right.ptr();leftChild->keys[leftChild->keyTally] = parent->keys[left.pos];leftChild - > keyTally + +;For (int n = 0;n < rightChild->;n++) {leftChild->keys[leftChild->keyTally] = rightChild->keys[n];if (!rightChild->leaf) {leftChild->指针[leftChild->keyTally] = rightChild->指针[n];} leftChild - > keyTally + +;} if (!rightChild->叶子)leftChild->指针[leftChild->键计数]= rightChild->指针[rightChild->键计数];For (int n = left.pos;n < parent-> keycount - 1;N ++) {parent->keys[N] = parent->keys[N + 1];Parent ->指针[n + 1] = Parent ->指针[n + 2];} rightChild->keyTally = -1;删除rightChild;母公司- > keyTally——;//由于合并从父节点删除了一个键,我们需要//立即检查父节点是否进入了//下流状态。此外,我们还必须检查一个重要的特殊//情况。如果我们刚刚删除了一个键的父节点实际上是//根节点,我们就不能通过正常的下流//策略来处理这个父节点。因为根节点没有兄弟节点,所以它不能参与//再平衡或合并。需要检查//的一个重要特殊情况是,根节点一直向下溢出到//,没有剩下键。在这种情况下,根将留下一个//单子指针,因此必须将子指针提升到//作为树的新根。if (parent-> keyally < M / 2 && parent != root) {BTreeLink<T, M> link = pointerToLink(parent);handleUnderflow(链接);} else if (parent == root && parent->leaf) {if (parent->leaf) root = nullptr;否则root = parent->指针[0];parent->keyTally = -1;删除父;}}
合并发生在方法上半部分的代码块中。注意,合并过程中的最后一个事件是显式删除rightChild节点。此时需要考虑的一个非常微妙的问题是,确保如果rightChild有任何子节点,我们最终不会意外地将它们连同节点本身一起删除。作为合并过程的一部分,指向rightChild子节点的指针将被转移到leftChild,但rightChild可能仍然保存着这些指针的副本。为了防止这些孙子被BTreeNode析构函数意外地删除,我们必须将rightChild的keyTally减少到-1。这确保了通常试图删除子节点的BTreeNode析构函数中的循环不会运行。这是一个非常微妙的细节,很容易被忽略,并且可能导致涉及节点双重删除的微妙且难以发现的错误。
c++ 11引入了一些非常重要的内存管理新工具,智能指针。智能指针是一种保存指向其他对象的指针的对象。智能指针做的第一件事是它可以假装自己是指针。智能指针通过重载解引用操作符operator*()和operator->()来实现这一点。
要在程序中使用智能指针,需要包含
智能指针通过跟踪对底层对象的引用来执行自动内存管理,然后在引用该对象的最后一个智能指针消失时自动删除该对象。下面是一个示例来演示它是如何工作的。这个例子使用了c++的shared_ptr类,它是c++中可用的两个智能指针类之一。
//一个简单的类class A {public: A();~ ();空白foo ();Private: //细节隐藏};std::要查看< > f () {std::要查看<一> ptr(新());ptr - > foo ();返回ptr;} int main() {std::shared_ptr<A> other = f();其他- > foo ();返回0;}
在本例中需要注意的重要事项是,我们从未对函数f()中创建的对象调用delete。我们不需要这样做,因为只要在f()中创建A对象,我们就把new A()返回的指针交给了shared_ptr。然后F()可以自由地将该指针的副本传递给main中的调用者。一旦退出f(),局部变量ptr就会消失,但到那时,ptr持有的指针副本已经从f()中传递出去了。PTR将感知到这一点,然后足够聪明,不会立即删除它指向的对象。相反,系统将等待,直到保存对象指针副本的最后一个shared_ptr消失。当我们到达main()的末尾时,就会发生这种情况。此时,other将意识到它是最后一个持有共享对象引用的shared_ptr对象,然后other将自动为我们删除该对象。
c++ 11提供了两个智能指针类,shared_ptr和unique_ptr。unique_ptr类实现独占赢博体育权语义。这意味着unique_ptr对象将声明它所指向的对象的独占赢博体育权。当unique_ptr对象消失时,它会自动为您删除所拥有的对象。此外,unique_ptr类还提供了一个move构造函数和一个move赋值操作符——它们都将一个拥有的指针从一个unique_ptr对象转移到另一个unique_ptr对象。在任何这样的移动操作中,指针移动的unique_ptr的内部指针值被重置为nullptr,从而放弃对它曾经拥有的对象的任何声明。
unique_ptr适合在类似BTree类的赢博体育程序中使用。在b树中,每个节点都可以说有一个唯一的赢博体育者。对于大多数节点,赢博体育者是节点的父节点。对于没有父节点的根节点,b树本身充当唯一的赢博体育者。这立即建议对BTree和BTreeNode类的结构进行以下更改:
模板<typename T, int M>类BTreeNode{好友类BTree<T, M>;好友类BTreeLink<T, M>;公众:BTreeNode ();BTreeNode(const T& el);~ BTreeNode ();bool isFull ();bool containsKey(const T& key);void insertKey(常量T& key);//用于插入一个新键到叶节点void中insertChild(const T& key, std::unique_ptr<BTreeNode<T, M>> child, int pos);void removeKey(const T& key);//用于从一个叶子中删除一个键BTreeLink<T, M> findSuccessor(const T& key);void swapWithSuccessor(const T& key, BTreeLink<T, b> leaf);Void遍历(std::ostream& out);无效快照(std::ostream& out);Private: bool leaf;int keyTally;T键[M - 1];std:: unique_ptr < BTreeNode < T M > >指针[M];int findKeyPos(const T& key);};模板<typename T, int M>类BTree {public: BTree();来~ ();bool contains(const T& key);void insert(const T& key);void remove(const T& key);Void遍历(std::ostream& out);无效快照(std::ostream& out);private: std::unique_ptr<BTreeNode<T, M>> root;//帮助预拆分策略的方法void splitRoot();void splitNode(const BTreeLink<T, M>& link);btreelode <T, M> *ptr);void handleUnderflow(const BTreeLink<T, M>& link);void rebalance(const BTreeLink<T, M>& left, const BTreeLink<T, M>& right);void consolidation (const BTreeLink<T, M>& left, const BTreeLink<T, M>& right);};
BTree类中的根指针现在是一个unique_ptr,而BTreeNode类中的数组指针现在是一个unique_ptr对象数组。
这带来的另一个直接变化是在这两个类的析构函数代码中:
模板<typename T, int M> BTreeNode<T, M>::~BTreeNode() {} template <typename T, int M> BTree<T, M>::~BTree() {}
这两个析构函数现在都可以完全为空,因为这两个类中嵌入的unique_ptr对象自动管理它们为我们保存的指针。当BTree或BTreeNode对象消失时,所包含对象的析构函数将自动运行。unique_ptr类的析构函数检查unique_ptr是否持有指针——如果持有,则析构函数自动对该拥有的指针调用delete。
在使用unique_ptrs存储对象指针的赢博体育程序中,我们通常希望unique_ptrs在创建对象的瞬间声明这些对象的赢博体育权。例如,在b树赢博体育程序中,我们必须在某个时刻为b树创建一个根节点。推荐的方法是替换传统的代码
root = new BTreeNode(key);
与
root = std::make_unique<BTreeNode<T,M>>(key);
这将创建一个用key初始化的新BTreeNode,并将节点的赢博体育权转移到根节点。
有时,将指针的赢博体育权从一个unique_ptr转移到另一个unique_ptr是合适的。在b树中的一个例子是在拆分、合并或再平衡操作中转移子指针。例如,下面是其中一个节点拆分方法的代码:
模板<typename T, int M> void BTree<T, M>::splitNode(const BTreeLink<T, M>& link) {BTreeNode<T, M> *toSplit = link.ptr();std::unique_ptr<BTreeNode<T, M>> right = std::make_unique<BTreeNode<T, M>>();For (int I = 0;i < (M - 2) / 2;我+ +)- >键[我]= toSplit - >键(i + (M - 1) / 2 + 1];if (!toSplit->leaf) {for (int i = 0;i < (M - 2) / 2 + 1;= std::move(toSplit->指针[i + (M - 1) / 2 + 1]);右->叶子=假;} right->keyTally = (M - 2) / 2;toSplit->keyTally = (M - 1) / 2;链接。insertChild(toSplit->keys[(M - 1) / 2], std::move(右),link.pos);}
这里的关键语句是
右->指针[i] = std::move(toSplit->指针[i + (M - 1) / 2 + 1]);
在这个语句中,被分割节点的一个子节点被转移到新节点,对吧?因为unique_ptr严格规范了赢博体育权从一个unique_ptr到另一个unique_ptr的转移,我们不能简单地说
右->指针[i] = toSplit->指针[i + (M - 1) / 2 + 1];
这将触发使用复制赋值操作符,该操作符实际上在unique_ptr类中声明为= delete。为了明确表示要进行移动,必须在std::move()中包装右侧的unique_ptr。这会触发使用move赋值操作符,而不是copy赋值操作符。move赋值操作符将拥有的指针从右侧的unique_ptr移到左侧的unique_ptr。
在这个方法的下面一点,我们必须将新节点的赢博体育权转移到父节点。这发生在方法的最后一条语句中,我们将一个新的键和一个新的子节点传递给父节点进行插入。insertChild的第二个参数是一个unique_ptr,因此我们必须在std::move中封装,将新节点的赢博体育权转移到参数。一旦right放弃了该节点的赢博体育权,它将其内部指针重置为nullptr。当我们退出该方法时,right的析构函数将运行,并将看到它不再拥有指针。因此,新节点对象将被安全地转移到父节点中的新主节点。
在许多使用unique_ptrs来管理对象赢博体育权的赢博体育程序中,我们还经常需要指向这些相同对象的普通指针。b树项目也不例外。BTree程序中一个重要的辅助类是BTreeLink类,它存储了一个指向节点父节点的指针:
模板<typename T, int M>类BTreeLink{好友类BTree<T, M>;公众:BTreeLink ();BTreeLink(BTreeNode<T, M>* par, int p);BTreeNode<T, M>* ptr() const{返回父->指针[pos].get();} BTreeLink<T, M> searchStep(const T& key);bool hasLeftSibling() const;BTreeLink<T, M> leftSibling() const;bool hasRightSibling() const;BTreeLink<T, M> rightSibling() const;private: BTreeNode<T, M>* parent;int pos;};
这个类不使用unique_ptr,而是存储指向父节点的原始指针。这是合适的,因为无论如何都不能说BTreeLink拥有它所引用的父节点。实际上,在同样使用unique_ptrs的赢博体育程序中使用原始指针是很常见的。在这种情况下,要遵循的第一个也是最重要的约定是明确原始指针永远不拥有它们所指向的对象。更具体地说,永远不应该对使用的任何原始指针调用delete。当实际拥有该对象的unique_ptr稍后试图删除该对象时,这很可能导致双重删除错误。使用原始指针的另一个危险是,拥有该对象的unique_ptr可能在任何时候决定删除该对象。一旦发生这种情况,存储在原始指针中的指针就会变成悬垂指针,并且尝试使用它可能会触发异常。
如果我们计划将原始指针与unique_ptrs一起使用,那么我们需要克服的最后一个技术障碍是弄清楚如何让unique_ptr为我们提供其所属指针的副本。unique_ptr类包含一个get()方法,该方法将把拥有的指针作为原始指针返回给您。这里有一个例子。
BTreeLink<T, M> link(root.get(), pos);
在这段代码中,我们设置了一个BTreeLink,它将引用root的一个子节点。BTreeLink类的构造函数的第一个参数需要一个指向BTreeNode的原始指针,因此我们在根上调用get()来获取该指针。
unique_ptr类和shared_ptr类都实现了包含和管理指针的对象。这两个类都利用了c++对象的一个关键方面:当对象创建时,构造函数运行,当对象消失时,析构函数运行。这两个类都使用它们的析构函数来决定是否删除指针指向的对象。
这种智能地使用构造函数和析构函数来实现特殊效果,是对更广泛的编程习惯用法RAII习惯用法的一种赢博体育。
RAII代表“资源获取即初始化”。使用这种习惯用法的类将使用其构造函数获取资源,然后使用其析构函数释放资源。
下面是另一个使用RAII习惯用法的简单类的具体示例。本例中的资源是用于从文件读取数字的文件流。类的构造函数被设计为打开文件,其析构函数被设计为关闭文件。
类FileReader {private: ifstream输入;public: FileReader(string fileName) {input.open(fileName);} ~FileReader() {input.close();} int read() {int x;输入> >x;返回x;}};
下面是一些示例代码来展示如何使用这个类。
int main() {FileReader in("numbers.txt");std::向量< int >;(int n = 0; n < 1000; n + +) A.push_back (in.read ());}
FileReader类负责管理输入流,在开始时为我们设置输入流,然后在到达main函数结束且FileReader的析构函数运行时自动关闭输入流。