11、TDD实战-栈数据结构:用TDD实现一个模板栈类

栈这个数据结构,大家应该都很熟悉了。后进先出,就像一摞盘子,你总是先拿最上面那个。但用TDD的方式来实现它,你会发现一些平时不太注意的细节。我个人习惯是,哪怕再简单的类,也先用测试把行为定下来,再动手写代码。

今天我们就来手把手走一遍这个流程。你会看到,测试驱动开发不是花架子,它真的能帮你写出更健壮的代码。

11.1 先写测试,再写实现

好,我们开始。第一步,想清楚栈要干什么。说白了就四个操作:push(压入)、pop(弹出)、top(查看栈顶)、isEmpty(判空)。

我先写一个最基本的测试用例。嗯,这里要注意,测试用例要足够小,小到让你能立刻写出实现代码。

// test_stack.cpp
#include <gtest/gtest.h>
#include "stack.h"

TEST(StackTest, NewStackIsEmpty) {
    Stack<int> stack;
    EXPECT_TRUE(stack.isEmpty());
}

TEST(StackTest, PushMakesStackNotEmpty) {
    Stack<int> stack;
    stack.push(42);
    EXPECT_FALSE(stack.isEmpty());
}

TEST(StackTest, PushAndTop) {
    Stack<int> stack;
    stack.push(10);
    EXPECT_EQ(stack.top(), 10);
}

TEST(StackTest, PushPopSequence) {
    Stack<int> stack;
    stack.push(1);
    stack.push(2);
    stack.pop();
    EXPECT_EQ(stack.top(), 1);
}

TEST(StackTest, PopOnEmptyThrows) {
    Stack<int> stack;
    EXPECT_THROW(stack.pop(), std::out_of_range);
}

TEST(StackTest, TopOnEmptyThrows) {
    Stack<int> stack;
    EXPECT_THROW(stack.top(), std::out_of_range);
}

你想想看,这些测试覆盖了哪些场景?正常流程、边界情况、异常处理。我在项目中遇到过很多次,就是因为没写边界测试,上线后才发现空栈调用pop直接崩了。所以,poptop在空栈时抛出异常,这个必须测。

11.2 让测试通过:实现模板栈类

测试写好了,现在让它们变绿。我选择用std::vector作为底层容器,因为它的动态扩容能力正好满足栈的需求。当然,你也可以用原生数组加手动管理,但没必要给自己找麻烦。

// stack.h
#pragma once
#include <vector>
#include <stdexcept>

template <typename T>
class Stack {
public:
    void push(const T& value) {
        data_.push_back(value);
    }

    void pop() {
        if (isEmpty()) {
            throw std::out_of_range("Stack::pop: empty stack");
        }
        data_.pop_back();
    }

    T& top() {
        if (isEmpty()) {
            throw std::out_of_range("Stack::top: empty stack");
        }
        return data_.back();
    }

    const T& top() const {
        if (isEmpty()) {
            throw std::out_of_range("Stack::top: empty stack");
        }
        return data_.back();
    }

    bool isEmpty() const {
        return data_.empty();
    }

    size_t size() const {
        return data_.size();
    }

private:
    std::vector<T> data_;
};

代码很简单,对吧?但这里有个坑——top()我提供了两个版本:非常量和常量版本。为什么?因为如果你有一个const Stack对象,你仍然需要能查看栈顶元素。我曾经在这个细节上吃过亏,接口设计时没考虑const正确性,后来重构改了一堆调用点。

关键设计决策top()返回引用而不是值。这样你可以修改栈顶元素,比如 stack.top() = 100;。但这也意味着调用者必须保证栈非空,否则就是未定义行为。我们的测试已经通过抛出异常来规避这个问题。

11.3 运行测试,验证一切正常

现在编译并运行测试。如果你用的是Google Test,命令大概是这样的:

g++ -std=c++17 -isystem /path/to/googletest/include \
    -pthread test_stack.cpp \
    /path/to/googletest/lib/libgtest.a \
    -o test_stack
./test_stack

你应该会看到所有测试通过,绿色一片。如果哪个红了,别慌,检查一下是不是实现和测试对不上。比如,我一开始忘了给pop加空栈检查,测试就挂了。这就是TDD的好处——你立刻知道哪里出了问题。

11.4 知识体系与核心逻辑

下面这张图总结了本章的核心逻辑。从测试用例出发,驱动出栈类的实现,最后验证通过。整个过程是循环迭代的。

TDD实现栈数据结构:核心流程 1. 编写测试用例 push/pop/top/isEmpty 2. 实现栈类 模板类 + vector容器 3. 运行测试 全部通过? 失败则修改代码 完成 关键设计点 top() 返回引用,支持修改栈顶元素 pop() 和 top() 在空栈时抛出 std::out_of_range 提供 const 和 non-const 两个版本的 top()

11.5 避坑指南与个人经验

做这个练习时,有几个地方容易翻车,我帮你列出来:

  • 忘记处理空栈异常:我曾经在项目里写过一个栈,pop时直接data_.pop_back(),没检查是否为空。结果线上服务在某个极端条件下调用了空栈的pop,导致vectorpop_back对空容器操作——未定义行为,程序直接崩溃。从那以后,我所有容器的边界操作都加了检查。
  • 模板代码的编译问题:模板的声明和实现必须放在同一个头文件中,或者使用.hpp文件。如果你把实现放在.cpp里,链接时会报找不到符号。这个坑我踩过不止一次。
  • 测试的独立性:每个测试用例都应该创建自己的栈对象,不要依赖测试之间的顺序。Google Test默认会重新创建对象,但如果你用了全局变量,就可能出问题。

小技巧:如果你想让栈支持移动语义,可以给push添加一个右值引用版本:void push(T&& value)。这样在压入临时对象时能避免拷贝,提升性能。我在写高性能计算模块时,这个优化效果很明显。

11.6 扩展思考

现在你的栈已经能工作了。但如果你想让它在多线程环境下安全运行呢?嗯,那就要加锁了。或者你想实现一个固定容量的栈,满了之后push会失败?这些都可以作为练习,用TDD的方式继续迭代。

记住,TDD的核心不是测试,而是用测试来驱动设计。你写的每一个测试,都在帮你明确接口的行为。代码是写给人看的,顺便让机器执行。测试就是那个「人」的说明书。

本章总结:我们通过6个测试用例,驱动出了一个完整的模板栈类。覆盖了正常操作、边界条件和异常处理。整个过程体现了TDD的「红-绿-重构」循环。你可以在自己的项目中试试这个流程,哪怕是一个简单的工具类,也能帮你发现不少设计上的盲点。


公众号:蓝海资料掘金营,微信deep3321