综合练习
这一课你将能做到
- 用类 +
std::vector+std::variant+ CMake 搭一个可运行的迷你判题器 CLI(submit/find) - 把 RAII 计时、optional 查询、variant 分派、移动语义串成一条完整链路
- 为核心逻辑写 doctest 单测,并用 ASan 验证一次内存安全,完成工程闭环
为什么要学
前十一课的技能点是散落的,拼成真实项目才算内化。本课用最熟悉的在线判题场景串起它们:类建模、vector、variant、optional、RAII、CMake、单测加 ASan。写完这约 150 行,你对「C++ 工程长什么样」就有了肌肉记忆,再读真实判题沙箱等开源代码就有了坐标系。
先建立图像
mini-judge/
├── CMakeLists.txt ← 构建声明(对应 pom.xml)
├── include/judge.hpp ← 公共接口
├── src/judge.cpp ← 判题核心
├── src/main.cpp ← CLI 入口(main 唯一)
└── tests/test_judge.cpp ← 单测(对应 JUnit)
跟着做
步骤 1:定义数据模型
新建 include/judge.hpp:
#pragma once
#include <optional>
#include <string>
#include <variant>
#include <vector>
namespace mj {
struct Submission {
int id;
std::string user;
std::string code;
};
struct AC { int costMs; };
struct WA { int caseIdx; };
struct TLE { int limitMs; };
using Verdict = std::variant<AC, WA, TLE>;
class Judge {
public:
explicit Judge(int timeLimitMs) : timeLimitMs_(timeLimitMs) {}
Verdict judge(const std::string& code) const;
std::optional<Verdict> resultOf(int submissionId) const;
void submit(Submission s);
const std::vector<Submission>& submissions() const { return subs_; }
private:
int timeLimitMs_;
std::vector<Submission> subs_;
std::vector<std::pair<int, Verdict>> results_;
};
} // namespace mj
步骤 2:实现判题核心
新建 src/judge.cpp:
#include "judge.hpp"
#include <algorithm>
#include <chrono>
namespace mj {
namespace {
// RAII 计时器:析构时写入耗时,异常路径也不漏
class ScopedTimer {
public:
explicit ScopedTimer(int& outMs)
: start_(std::chrono::steady_clock::now()), out_(outMs) {}
~ScopedTimer() {
out_ = static_cast<int>(
std::chrono::duration<double, std::milli>(
std::chrono::steady_clock::now() - start_).count());
}
private:
std::chrono::steady_clock::time_point start_;
int& out_;
};
// 模拟运行用户代码;真实沙箱在此 fork 子进程并限制资源
int runOneCase(const std::string& code, int caseIdx) {
int h = 0;
for (char c : code) h = h * 31 + c + caseIdx;
return h % 200 + 1;
}
} // namespace
Verdict Judge::judge(const std::string& code) const {
for (int i = 0; i < 5; ++i) {
int cost = 0;
{
ScopedTimer t(cost);
cost += runOneCase(code, i);
}
if (cost > timeLimitMs_) return TLE{timeLimitMs_};
if (i == 2 && code.find("wrong") != std::string::npos)
return WA{i};
}
return AC{runOneCase(code, 0)};
}
void Judge::submit(Submission s) {
int id = s.id;
subs_.push_back(std::move(s)); // 移动避免深拷贝
results_.emplace_back(id, judge(subs_.back().code));
}
std::optional<Verdict> Judge::resultOf(int submissionId) const {
auto it = std::find_if(results_.begin(), results_.end(),
[submissionId](const auto& rv) { return rv.first == submissionId; });
if (it == results_.end()) return std::nullopt;
return it->second;
}
} // namespace mj
步骤 3:CLI 入口
新建 src/main.cpp:
#include "judge.hpp"
#include <iostream>
#include <string>
#include <variant>
void printVerdict(const mj::Verdict& v) {
std::visit([](const auto& r) {
using T = std::decay_t<decltype(r)>;
if constexpr (std::is_same_v<T, mj::AC>)
std::cout << "AC (" << r.costMs << "ms)";
else if constexpr (std::is_same_v<T, mj::WA>)
std::cout << "WA (第" << r.caseIdx + 1 << "个测试点)";
else
std::cout << "TLE (限" << r.limitMs << "ms)";
}, v);
std::cout << "\n";
}
int main() {
mj::Judge judge{100};
std::string cmd;
while (std::cout << "> " && std::cin >> cmd) {
if (cmd == "quit") break;
if (cmd == "submit") {
mj::Submission s{};
std::cin >> s.id >> s.user >> s.code;
judge.submit(std::move(s));
std::cout << "评测完成: ";
printVerdict(*judge.resultOf(s.id));
} else if (cmd == "find") {
int id;
std::cin >> id;
auto r = judge.resultOf(id);
if (r) printVerdict(*r);
else std::cout << "没有这个提交\n";
}
}
return 0;
}
步骤 4:CMake 组装
新建 CMakeLists.txt:
cmake_minimum_required(VERSION 3.16)
project(mini_judge CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
add_library(judge_core src/judge.cpp)
target_include_directories(judge_core PUBLIC include) # PUBLIC 传递 include 路径
add_executable(mini_judge src/main.cpp)
target_link_libraries(mini_judge PRIVATE judge_core)
cmake -B build && cmake --build build 后运行 ./build/mini_judge:submit 1 zhang fast 得 AC (17ms),submit 2 li wrong 得 WA (第3个测试点)。
步骤 5:单测锁定核心
新建 tests/test_judge.cpp:
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "doctest.h"
#include "judge.hpp"
TEST_CASE("超时代码判 TLE") {
mj::Judge j{10};
j.submit({1, "li", "slow slow slow slow slow"});
CHECK(std::holds_alternative<mj::TLE>(*j.resultOf(1)));
}
TEST_CASE("含 wrong 的代码在测试点 3 判 WA") {
mj::Judge j{1000};
j.submit({2, "wang", "wrong answer"});
auto* wa = std::get_if<mj::WA>(&*j.resultOf(2)); // get_if:不抛异常的取法
REQUIRE(wa != nullptr);
CHECK(wa->caseIdx == 2);
}
CMake 追加测试 target,重新构建后在 build 目录跑 ctest,全绿即通过:
add_executable(test_judge tests/test_judge.cpp)
target_link_libraries(test_judge PRIVATE judge_core)
enable_testing()
add_test(NAME judge_tests COMMAND test_judge)
再用 ASan 把整个工程过一遍(第 11 课):
cmake -B build-asan -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_FLAGS="-fsanitize=address" -DCMAKE_EXE_LINKER_FLAGS="-fsanitize=address"
cmake --build build-asan && ./build-asan/test_judge
无输出即无内存错误——代码没有裸 new,本就该干净。进阶可自选:批量 submit 加线程与互斥锁(第 9 课)、vcpkg 装 nlohmann-json 落盘(第 10 课)、对比 Debug/Release 耗时(第 11 课)。
讲清楚要点
- 库与可执行分离是工程化第一步:核心逻辑进 judge_core 库,被 CLI、测试、未来接口复用,对应 Java 的 core/app 分层。
- variant + visit 替代结果码:新增一种结果只需加一个 struct 和一个分支,编译器强制补全所有分发点。
- RAII 贯穿始终:ScopedTimer 管计时窗口,vector 管存储,string 管缓冲——零裸 new、零手动清理。
- 接口用 optional/const& 表达契约:返回 optional 说明「可能没有」,返回
const&说明「只读视图」。 - 提交前三连:ctest 全绿 + ASan 无输出 + Release 冒烟——判题沙箱的 CI 就是这三步的放大版。
你可能会懵的地方
-
为什么 main.cpp 和 judge.cpp 要分开? main 只做 IO 与分发,业务逻辑全在库层,单测直接链接 judge_core——Java 的 Controller/Service 分层同理。
-
results_为什么存 pair 而不是 map? 提交 ID 可能重复也可能不连续,map 会去重;vector 保序留全历史,规模大再换unordered_map<int, Verdict>。 -
judge 是 const 成员函数,为什么能返回 TLE{...}? const 只承诺不改成员,构造返回值、读成员都合法;它把「judge 只读、submit 才写」画在接口上。
-
想做成「真」判题器还差什么? 把 runOneCase 换成真进程执行:fork、管道重定向、限时限存、超时 kill、信号分类 RE;工程结构不变,系统调用层更深。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 所有代码塞进 main.cpp | 无法单测无法复用 | 拆库 + 入口,CMake 分 target |
| 结果用 int 码 + 注释 | 魔法数字散落,漏分支无警告 | variant + visit,编译器穷尽检查 |
| submit 参数用值传且不 move | 字符串深拷贝 | 传值后 std::move 入容器 |
卡点急救
- 链接错误
undefined reference to mj::Judge::judge:judge.cpp 没进add_library,或 main 没挂 judge_core。 - 单测找到两个 main:忘了
#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN。 - CLI 输入错乱:
>>读数字后残留换行被getline吞掉,>>后接cin.ignore()清缓冲。
面试常问
-
介绍一个你用 C++ 写过的完整项目,如何组织的? 按分层讲:数据模型(struct/variant)→ 核心逻辑(类 + RAII)→ 入口适配 → 构建(CMake 分 target)→ 质量(doctest + ASan);强调所有权清晰、接口语义显式、构建可复现。
-
C++ 项目里如何保证内存安全? 编译期禁裸 new/delete,用智能指针表达所有权;测试期单测叠加 ASan 与 TSan 跑 CI;运行期日志 + core dump 兜底。原则:能移动不复制、能栈不堆。
-
你的项目里为什么用 variant 而不是继承多态? 结果类型集合封闭、对象小且值语义友好,variant 无堆分配无虚表、visit 可穷尽检查;要做开放扩展(第三方注册新类型)才切继承 + 虚函数。
自测三问
- 为什么把 judge 做成库 target 而不是把逻辑全写进 main.cpp?
std::get_if<mj::WA>(&v)相比std::get<mj::WA>(v)的优势是什么?- 项目里哪些对象的生命周期由 RAII 管理?各举一例。
小结一句话
类 + vector + variant + RAII 计时 + optional + CMake 分层 + 单测/ASan——150 行迷你判题器串起十二课,骨架直接对应真实判题沙箱的工程结构。
延伸
- cppreference:C++ 教程索引
- cplusplus.com:C++ 完整教程目录
- 下一课预告:本轨完结。进阶可深入 C++20 Concepts/协程与开源判题沙箱源码。