Testlib 从入门到精通
前言
Testlib 是由 Codeforces 的创办者 Mike Mirzayanov 打造的一份专注于数据层面的 C++ 功能库,其中包含 Generator、Validator、Interactor 和 Checker 四个模块,基本覆盖了出题流程中数据相关的全部环节。
这个项目存放在 GitHub 上。你可以从这里查看这个项目,或者从 Release 页面下载整个库和示例文件(不过,这个仓库似乎很久都没有发布新的 Release 了)。
本文基于 Testlib 于 2025 年 12 月 18 日发布的版本,可以使用这个链接查看修改历史。另外,本文的介绍重点在于 Testlib 的使用方式上,而技术细节将会通过折叠框的形式出现。
1 基础介绍
这一章节主要讨论 Testlib 的基本属性,从而能够快速上手。
1.1 使用环境
Testlib 提供了五种使用环境:
- Generator,即数据生成器,你可以使用内置的
rnd随机库以及灵活的命令行参数操作生成一些输入数据; - Validator,即数据校验器,在严格模式下,你可以逐字符识别通过数据生成器或者选手输入得到的数据输入文件;
- Interactor,即交互器,你可以使用它实现 IO 交互题的交互器;
- Checker,即自定义检测器,可以实现单测试点的特殊评分策略;
- Scorer,即自定义评分器,需要根据所有测试数据的运行结果给出浮点数分值,一般用于实现多测试点的特殊评分策略。
其中,Scorer 作为一个实验性的环境,在一些主流 OJ 上并不会经常出现(或者被自定义计分脚本等功能替代),其主要出现在综合性较强、评分方式较复杂的比赛中,例如某些 IOI 赛制的赛事,以及 Codeforces 上的 Challenge 等。我们将会在独立的章节介绍这一环境。
为了使用 Testlib 编写相应的程序,你需要遵循如下规则:
- 必须通过
#include "testlib.h"的方式引入头文件,并将它放在其余所有头文件引用之前,以便 Testlib 对标准库实现进行一定的控制; - 在
main函数中添加argc和argv参数,如main(int argc, char* argv[]),以捕获命令行参数; - 在
main函数的第一个语句应该是环境初始化专用的函数,你可以在后面的例子中学习如何初始化环境。
接下来将会简单介绍前四种环境的使用方法。
1.1.1 Generator
Generator 仅用于创建测试数据,因此它不会重定向输入输出。在使用之前,需要使用 registerGen(argc, argv, 1) 语句初始化。随后,你可以直接和生成器交互,生成器也会直接将数据输出到标准输出流。以下包含三种可能的数据构造方式:
- 通过文件以及输入重定向传入参数。此时需要把参数写在一个文件内,然后使用输入重定向提供给 Generator,例如
gen.exe < opt.in或./gen < opt.in。
::::info[例:给定长度,生成仅包含字母和数字的随机字符串]{open}
在 gen.cpp 中实现如下内容:
#include "testlib.h"
#include <iostream>
using namespace std;
int main(int argc, char* argv[]) {
registerGen(argc, argv, 1);
int len;
cin >> len;
cout << rnd.next("[a-zA-Z0-9]{%d}", len) << endl;
return 0;
}
随后进行编译,假设编译结果为 gen.exe,则可以直接通过 gen.exe > data.in 命令启动 Generator。随后,只需要输入一个正整数,就能将对应长度的随机字符串输出到 data.in 文件中。
::::
::::error[这是不推荐的实现方式!]{open} 实际上,Testlib 希望将所有需要使用的参数通过命令行给出。在后面的技术分析中会提到,Testlib 使用命令行参数的哈希值作为随机种子,因此在命令行参数相同的前提下,生成的所有数据都使用了同一个随机种子,可能会导致意料之外的问题。 ::::
- 通过命令行传入参数。你可以通过
opt(index)和opt(index, default_value)函数得到命令行的参数值,例如在运行gen.exe 1 2 3时,有opt(0) = "gen.exe",opt(1) = "1",opt(2) = "2"以及opt(3) = "3"。另外,你还可以通过opt<T>(index)的方式直接解析字符串对应的值,具体规范详见第十章“命令行参数解析”。
::::info[例:给定长度生成随机字符串(另解)]{open}
在 gen.cpp 中实现如下内容:
#include "testlib.h"
#include <iostream>
using namespace std;
int main(int argc, char* argv[]) {
registerGen(argc, argv, 1);
int len = opt<int>(1);
cout << rnd.next("[a-zA-Z0-9]{%d}", len) << endl;
return 0;
}
随后进行编译,假设编译结果为 gen.exe,通过 gen.exe 100 > data.in 命令启动 Generator,即可将一个长度为 100 的随机字符串输出到 data.in 文件中。
::::
- 通过 Testlib 的命令行解析传入参数。在
registerGen函数中,Testlib 会将命令行参数进行解析,并得到若干键值对。在实际使用时,可以使用opt<T>(key)和opt<T>(key, default_val)的方式获取键对应的值。具体规范详见第十章“命令行参数解析”。
::::info[例:给定点数,生成链、菊花或树]{open}
在 gen.cpp 中实现如下内容:
#include "testlib.h"
#include <iostream>
using namespace std;
int main(int argc, char* argv[]) {
registerGen(argc, argv, 1);
int n = opt<int>("n");
if (opt<bool>("chain")) {
// 链
cout << n << endl;
for (int i = 1; i < n; i ++)
cout << i << " " << i + 1 << endl;
} else if (opt<bool>("daisy")) {
// 菊花
cout << n << endl;
for (int i = 2; i <= n; i ++)
cout << 1 << " " << i << endl;
} else {
// 树
cout << n << endl;
for (int i = 2; i <= n; i ++)
cout << rnd.next(1, i - 1) << " " << i << endl;
}
return 0;
}
随后进行编译,假设编译结果为 gen.exe,随后:
- 通过
gen.exe -n 100可以生成包含 100 个点的随机树; - 通过
gen.exe -n 100 --chain可以生成包含 100 个点的链; - 通过
gen.exe -n 100 --daisy可以生成包含 100 个点的菊花图。
::::
1.1.2 Validator
Validator 用于验证输入数据的完整性,需要使用 inf 变量(注:这个变量与 ouf、ans 都是 InStream 类的一个实现)从标准输入读取输入数据文件。在使用之前,你需要使用 registerValidation(argc, argv) 语句初始化。一般来说,Validator 从输入文件读取信息,并对数据的完整性进行判断(例如测试图结构是否联通、询问参数是否符合题目要求等)。在一些情况下,你可能还需要将标准算法嵌入 Validator 中。
为了保证严格检验,你应当只使用 inf 变量读入,并且精确匹配所有字符,包括但不限于空白字符与文本结束标志(EOF)。一般来说,可以使用 inf.read??? 或 inf.read???s 读取信息,并使用 inf.readSpace()、inf.readEoln() 和 inf.readEof() 等方式处理空白字符。本章节的“基础函数”部分包含了一些常用的方法。关于严格模式读取的完整规则,详见第五章“读入相关类”。
::::info[一个简单的示例]{open}
在 val.cpp 中实现如下内容:
#include "testlib.h"
using namespace std;
int main(int argc, char *argv[]) {
registerValidation(argc, argv);
int n = inf.readInt(1, 100000, "n");
inf.readEoln();
inf.readLongs(n, -1000000000LL * 1000000LL, 1000000000LL * 1000000LL, "a");
inf.readEoln();
inf.readEof();
}
这个例子来自官方仓库下的 /validators/nval.cpp,对应的要求为:
- 输入的第一行仅包含一个整数
n,且在[1, 1e5]范围内; - 输入的第二行包含
n个用空格分隔的整数,且均在[-1e15, 1e15]范围内; - 输入仅在第一行和第二行结尾恰好出现一个换行符,且没有除上述内容之外的任何额外内容。
随后进行编译,假设编译结果为 val.exe,通过 val.exe < data.in 即可进行验证。
::::
1.1.3 Checker
Checker 用于根据数据文件和选手程序输出给出对应的判分结果。在使用之前,你需要使用 registerTestlibCmd(argc, argv) 语句初始化。随后,Checker 可以使用如下变量读取对应的文件信息:
inf与输入数据文件绑定;ouf与选手程序输出绑定;ans与输出数据文件绑定。
你同样可以使用类似 inf.read??? 或 inf.read???s 的方法读取信息,并在代码中实现相应的判断逻辑。
Checker 必须通过 quit 系列函数退出,并显式给出判分结果和提示信息。最常见的 quit 系列函数为 quitf(result, format, ...),其中 result 为判分结果,而 format 与后面的不定长参数共同形成提示信息。可用的 result 类型如下:
_ok,表示选手程序输出正确;_wa,表示选手程序输出错误;_pe,表示选手程序输出存在格式错误;_fail,表示 Testlib 环境或数据出现错误;_dirt,表示选手程序输出存在多余的信息,在非 Interactor 环境下出现选手程序输出未完全读取的情况时自动抛出,随后在内部转化为_pe;_points,表示选手程序获得部分分值(或部分分信息),应通过quitp系列函数给出;_unexpected_eof,在 Testlib 通过InStream类读取内容却发现文件已被读完时自动触发,如果不定义ENABLE_UNEXPECTED_EOF宏则在内部转化为_pe;_partially,表示“部分正确类型”的基值,不应直接使用。若部分正确类型编号为x,应通过_pc(x)给出结果(例如调用quitf时传入_pc(x))。
最后,每个 Checker 都拥有一个名字,用于显示帮助信息。默认的名字为 untitled checker,你可以使用 setName(format, ...) 函数修改。
::::info[示例:大小写不敏感的 YES/NO 判断]{open}
在 check.cpp 中实现如下内容:
#include "testlib.h"
#include <string>
using namespace std;
const string YES = "YES";
const string NO = "NO";
int main(int argc, char * argv[]) {
registerTestlibCmd(argc, argv);
std::string ja = upperCase(ans.readWord());
std::string pa = upperCase(ouf.readWord());
if (ja != YES && ja != NO)
quitf(_fail, "%s or %s expected in answer, but %s found", YES.c_str(), NO.c_str(), compress(ja).c_str());
if (pa != YES && pa != NO)
quitf(_pe, "%s or %s expected, but %s found", YES.c_str(), NO.c_str(), compress(pa).c_str());
if (ja != pa)
quitf(_wa, "expected %s, found %s", compress(ja).c_str(), compress(pa).c_str());
quitf(_ok, "answer is %s", ja.c_str());
}
这个例子来自官方仓库下的 /checkers/yesno.cpp,用于判定输出中是否存在大小写不敏感的 Yes 或 No,并验证选手输出是否和输出数据文件匹配。此时:
- 如果输出数据文件不存在
Yes或No,则返回_fail,因为此时数据出现了问题; - 如果选手输出文件不存在
Yes或No,则返回_pe,表示输出格式有误; - 如果选手输出和输出数据文件不匹配,则返回
_wa,表示答案错误; - 如果上述验证均通过,则返回
_ok。
随后进行编译,假设编译结果为 check.exe,通过 check.exe data.in user.out data.ans 即可获得判分结果。例如:
- 若
data.ans为YES,user.out为yes,则返回_ok; - 若
data.ans为YES,user.out为maybe,则返回_pe; - 若
data.ans为YES,user.out为NO,则返回_wa。
关于各判分结果的完整语义、退出函数以及 appes 模式输出格式,详见第八章“退出函数和断言函数”。 ::::
1.1.4 Interactor
Interactor 主要用于 I/O 交互中,通过与选手程序的标准输入输出流建立连接实现交互功能。在使用之前,你需要使用 registerInteraction(argc, argv) 语句初始化。与其余三个使用环境不同,Interactor 涉及到多个输入输出流:
inf与输入数据文件绑定;ouf与选手程序的输出流绑定,是一个在线流,只要选手程序刷新其输出缓冲区,Interactor 就能从ouf中读取;ans与输出数据文件绑定;- Interactor 的标准输出流和选手程序的输入流绑定,也是一个在线流,只要 Interactor 输出内容并刷新输出缓冲区,就能将内容传递给选手程序作为输入;
tout是 Interactor 的特殊输出流,其类型与inf等均不同,为fstream。你可以使用和cout类似的语法输出内容,而这些内容将会被写入结果文件中,作为 Checker 的选手输出文件(即ouf)。
对于一个 I/O 交互题,以下为整体的测试流程:
- 首先,Interactor 会和用户程序进行交互,过程中 Interactor 应当通过
quit系列函数退出,并将交互过程的摘要写入结果文件。 - 随后,如果 Interactor 返回的结果为
_ok,则 Checker 会检查结果文件,给出交互过程对应的判分结果; - 最终判分结果应由 Interactor(当其返回结果不是
_ok时)或 Checker(当 Interactor 返回结果为_ok时)给出。
::::info[示例:I/O 交互猜数的配置方案]{open} 这一部分的内容来自 Polygon 的交互题示例。考虑如下问题:
:::info[I/O 交互猜数]{open}
现有一个不超过
首先,从标准输入中读取一个整数
- 如果
x < y ,则可以从标准输入中读到字符串<; - 如果
x \geq y ,则可以从标准输入中读到字符串>=。
你需要在不超过 ! x 的格式输出。需要注意的是,本题的交互是适应性的,
下面是官方给出的 Interactor 实现:
#include "testlib.h"
#include <bits/stdc++.h>
using namespace std;
void upd(int &lf, int &rg, int x, int y) {
if (x > y)
return;
lf = max(lf, x);
rg = min(rg, y);
}
void send(string x) {
cout << x << endl;
fflush(stdout);
}
const int INF = 1000'000'000;
int main(int argc, char *argv[]) {
registerInteraction(argc, argv);
int x = inf.readInt();
int n = inf.readInt();
cout << n << endl << flush;
int lf = 1, rg = n;
int queries = 0;
while (true) {
bool is_answer = false;
string cur = ouf.readToken("!|[1-9][0-9]{0,8}");
int last;
if (cur != "!") {
InStream tmp(ouf, cur);
last = tmp.readInt(-INF, INF);
queries++;
} else {
is_answer = true;
last = ouf.readInt(-INF, INF);
}
if (last < 1 || last > n)
quitf(_pe, "number %d from stdin is out of range [%d, %d]", last, 1, n);
if (is_answer) {
if (last == x && lf == rg) {
tout << queries << endl;
quitf(_ok, "number is guessed.");
} else if (last == x && lf != rg)
quitf(_wa, "number is but it was made in a random way");
else
quitf(_wa, "guessed number is incorrect");
}
if (x < last) {
send("<");
upd(lf, rg, 1, last - 1);
} else {
send(">=");
upd(lf, rg, last, n);
}
}
}
简单来说,上述 Interactor 执行了如下流程:
- 从数据输入文件读取
x和n; - 不断从选手的标准输出流(
ouf)读取一个字符串,并检验其是否能被正则描述!|[1-9][0-9]{0,8}匹配;- 如果上述字符串为
!,则继续从ouf读取询问值y ,否则以ouf为参照建立一个InStream,并读取这个字符串对应的整数x' ; - 如果是询问,则给出对应的询问结果,并将这一信息进行收集(通过
upd函数修改目前可能的x 值区间); - 否则,若答案错误,或者可能的
x 值区间包含多个可能值(因为交互是适应性的),则视为错误,返回_wa,否则返回_ok。
- 如果上述字符串为
上述高亮行同时使用了 pattern 类和 InStream 类的特性,在直观上理解其功能并不难。更多信息详见第三章“简易正则表达式”和第五章“读入相关类”。
对应的,Checker 的代码如下:
#include "testlib.h"
int main(int argc, char *argv[]) {
registerTestlibCmd(argc, argv);
int oufq = ouf.readInt();
int ansq = ans.readInt();
if (ansq > 25)
quitf(_fail, "Limit is %d, but main solution have made %d queries", 25, ansq);
if (oufq > 25)
quitf(_wa, "Limit is %d, but solution have made %d queries", 25, oufq);
int n = inf.readInt();
int m = inf.readInt();
quitf(_ok, "Number %d is guessed successfully (range [1..%d]) with %d queries", n, m, oufq);
}
需要注意的是,上述代码在检测选手程序的交互次数是否符合限制之前,预先检测了标准实现是否符合限制,以保证健壮性。
::::
::::info[如果你需要在洛谷配置交互题……]{open}
在洛谷的交互题环境中,为了支持函数式交互,选手程序将会和数据包中的交互库 interactive_lib.cpp 一同编译,并直接和 Checker 进行 I/O 交互。换句话说,此时的 Checker 同时也承担了 Interactor 的职责,因此相关的输入输出流需要参考 Interactor 的实现。
在 I/O 交互题中,可以直接将 interactive_lib.cpp 置空,而在函数式交互题中,出于安全考虑,需要实现如下步骤:
- 设计交互库与 Checker 之间的协议,使得即便选手能看到本次交互内容,也无法据此获得未授权信息或构造可通过校验的伪造交互;并尽量避免提前泄露未来调用信息(一种可行的方案是通过一次一传、按需传输的形式实现函数调用);
- 在 Checker 中,根据通信协议完成函数调用参数与返回值的处理,并进行判分;
- 在交互库中,借助通信协议接收来自 Checker 的函数调用请求,并将函数调用结果传输给 Checker。
一般来说,交互库作为和选手程序一同编译的文件,应当将尽可能少的信息暴露给选手程序。因此,为了实现可控的外部链接性,推荐函数式交互题的交互库实行如下规则:
- 除了
main函数和必要的extern声明外,将所有全局变量和函数放在一个匿名命名空间内(即namespace { ... }),预防意料之外的信息泄露; - 不建议在交互库中直接引入 Testlib。Testlib 可能通过在全局命名空间重新定义
rand/srand等函数实现禁用,这会在最终链接产物中覆盖同名符号,从而影响选手程序及其依赖库的行为;同时还可能引入额外的全局状态与初始化逻辑,增加不可预期性。
对于函数式交互,在一些特殊情况下,Checker 可以被省略,此时交互库可以直接从输入文件读取调用信息,并输出选手函数的调用结果,与标准输出进行比对。在这个情况下,如果希望测试流程尽可能安全,你还需要额外注意如下问题:
- 在调用选手函数之前完成输入文件的读取,并且约定输入文件的特殊格式,防止选手通过特殊手段(例如
fseek、结构体构造函数内读入)获取调用信息; - 约定输出文件的特殊格式,防止选手通过某种方式篡改交互库的输出内容(一种可行的方式为,将所有调用信息储存在一个类中,并在析构函数中离线计算所有调用需求,仿造另一份输出并就地覆盖)。
因此,在输入或输出文件缺少特殊格式的前提下,不推荐使用无 Checker 的配置方案。 ::::
1.2 编译环境与运行参数
在本地编写程序后,可以使用任意一种工具链将代码和 testlib.h 头文件一起编译为可执行文件。需要注意的是,Testlib 的 C++ 标准版本的最低要求为 C++11,并且不应当使用 -ffast-math 编译开关。在编译完成后,可以通过 --help 参数显示 Testlib 版本、Checker 名称与详细的 Changelog。
前面的介绍给出了基础的调用方法,而接下来将会详细介绍所有可用的参数和开关。
1.2.1 Generator
Generator 的所有命令行参数都用于设置数据参数,因此没有额外的说明。
1.2.2 Validator
对于 Validator 而言,可以添加如下可选参数:
:::align{center}
| 参数 | 功能 |
|---|---|
--testset testset |
设置数据对应的 Testset,在代码中可以通过 validator.testset() 获得,默认值为 tests |
--group group |
设置数据对应的 Group,在代码中可以通过 validator.group() 获得,默认值为空 |
--testOverviewLogFileName fileName |
设置日志总览文件的位置,其格式将会在“Validator 规范”章节展开 |
--testMarkupFileName fileName |
设置标记文件的位置,包含 Validator 读取的所有字符和测试编号信息 |
--testCase testCase |
设置一个特定的测试编号,用于追踪这个测试编号对应的输入数据 |
--testCaseFileName fileName |
设置单测试数据文件的位置,包含编号为特定测试点编号的测试输入 |
:::
参数中的文件信息仅在文件位置被指定时才会写入,而文件位置可以使用 stdout 和 stderr 指定为标准流。另外,上述参数也可以使用 validator 变量的成员函数设置,如 validator.setTestset(testset) 等。有关输出文件的详细信息,详见第六章“Validator 规范”。
1.2.3 Checker
对于 Checker 而言,需要按照下面的方式编写参数:
check.exe <Input_File> <Output_File> <Answer_File> [<Result_File> [-appes]]
其中前三个文件分别是输入数据文件、选手程序输出和输出数据文件,为必需参数。后面的则为可选参数,其中 Result_File 指定了结果信息文件,此时 quit 系列函数给出的提示信息将会输出到这一文件中,而 -appes 参数是在此基础上将结果信息改为 XML 格式,具体格式详见第八章“退出函数和断言函数”。
Checker 也可以在最后追加 testset 和 group 参数,方式和 Validator 类似。在代码中,可以使用 getTestset() 和 getGroup() 获得。
1.2.4 Interactor
对于 Interactor 而言,需要按照下面的方式编写参数:
interactor.exe <Input_File> <Output_File> [<Answer_File> [<Result_File> [-appes]]]
其中 Input_File 表示输入数据文件,Output_File 为结果文件,它们共同构成必需参数。后面的则为可选参数,其中 Answer_File 为输出数据文件,剩余参数和 Checker 一致。
和 Checker 一样,Interactor 也可以在最后追加 testset 和 group 参数。
1.3 基础函数
此小节将会介绍一些常用的基础函数。
1.3.1 读入相关
前面提到,inf、ouf 和 ans 都是 InStream 类的实现。实际上,InStream 类拥有丰富的读入方法,其中常见的读入函数可按下表理解:
| 类别 | 常用函数 | 说明 |
|---|---|---|
| 单值读取 | readInt()、readLong()、readDouble()、readToken() 和 readLine() |
分别用于读取整数、长整数、浮点数、一个字符串和一行字符 |
| 批读取 | readInts(n, ...)、readTokens(n, ...) 等 |
一次读取多个同类元素 |
| 空白字符读取 | readSpace()、readEoln()、readEof() |
在严格模式中精确读取空格、换行和文件结尾 |
| 额外限制 | readInt(1, 1e9, "n")、readToken("[a-z]+") |
通过范围限制或格式限制增强校验强度 |
如果需要查看这些函数的完整重载、可选参数(如范围限制、变量名、索引基值)与严格模式差异,详见第五章“读入相关类”。
1.3.2 判分结果相关
在 Testlib 中,判分结果相关的常见用法如下表所示:
| 场景 | 函数 | 功能 |
|---|---|---|
| 主动给出判分结果 | quitf(result, ...) 和 quit(result, ...) |
直接给出判分结果和提示信息 |
| 给出部分分值 | quitp(points, message) 和 quitpi(points_info, message) |
给出 _points 作为判分结果 |
| 给出“部分正确类型” | quitf(_pc(x), ...) |
给出 _partially 作为判分结果 |
| 读入或处理选手程序时错误 | ouf 相关读入/断言失败 |
通常给出 _wa 或 _pe 作为判分结果 |
| 读入或处理数据文件时错误 | inf/ans 相关读入/断言失败 |
通常给出 _fail 作为判分结果 |
_ok、_wa、_pe、_fail 是最常见的主动判分结果,覆盖了大多数 Checker 与 Interactor 的实现场景。有关更多判分结果或退出函数的信息,详见第八章“退出函数和断言函数”。
1.3.3 断言相关
你可以在代码中使用 ensure(condition) 函数,在 condition 为假的时候将会直接判作 _fail 并且返回该条件的提示信息。这个函数的功能和 assert 类似,不过错误处理更符合 Testlib 的规范。你也可以使用 ensuref(condition, format, ...) 自定义提示信息。
::::info[全局实现和 InStream 内部实现]{open}
实际上,quit 系列函数与 ensure 系列函数在全局以及 InStream 中都有实现。
对于 quit 系列函数而言,如果 InStream 类型是选手程序输出(如 ouf.quitf),则保留判分类型,否则认为测试数据出现问题,重新判为 _fail。全局的 quit 系列函数仅调用了 ouf 对应的成员函数。
对于 ensure 系列函数,无论 InStream 类型如何,都会判为 _wa,并使用 InStream::quit 或 InStream::quitf 退出。这意味着 _wa 状态同样经过了上述 quit 系列函数的处理,对于非选手程序输出类型的情况会转化为 _fail。如果调用全局实现,将会直接判为 _fail,即认为错误出自 Testlib 环境。
具体的原理涉及到退出时身份,其细节详见第八章“退出函数和断言函数”。 ::::
1.4 注意事项
在使用 Testlib 时,你需要注意以下几个要求:
- 不要使用
srand(),rand()和random_shuffle(),因为它们无法保证命令行参数和数据一一对应,请使用 Testlib 提供的rnd对象和shuffle()函数。 - Checker 的工作流程应该尽可能短,如果一些预处理工作需要较长的时间,可以将结果放在答案文件中,Checker 只需要读入使用即可;
- 在 Checker 中,即使选手输出文件有不必要信息,在返回结果的时候仍然需要完全读取,否则会判为 Wrong Answer。
- 如果选手的答案比标准答案更优秀,应该返回
_fail并且让选手和题目负责人联系。 - 尽管在后面的内容中有提及,但不推荐使用
long类型相关的函数,因为 Testlib 无法保证long类型占据的比特数。 - 在必要的情况下,尽可能保证配置的安全性,防止选手通过各种方式找到非预期行为。
另外,Testlib 的代码仓库中包含了一些示例实现,如果题目只需要简单的判断(例如整数或浮点数的正确性判断),可以直接使用里面的代码。
::::success[如果你读到这里……]{open}
恭喜你!你已经可以利用 Testlib 环境编写简单而实用的程序了。后续的介绍会进一步介绍各部分实现的细节,防止重复造轮子的情况出现(例如用 pattern 实现读取验证和字符串随机、用 InStream 模拟 stringstream 等)。
后续的介绍会根据 Testlib 的代码顺序展开,如果感兴趣的话可以边看文章边看代码。 ::::
2 跨平台支持
本章节对应 Testlib 代码中第 197 行开始的部分。
为了将 Testlib 接入本地或线上的测试环境,需要了解 Testlib 在各平台上的行为。这一章节主要讨论这一部分代码中设置的退出码系统。实际上,Testlib 原生支持 eJudge 和 Contester(注:现已无法访问),并对应设置了这些测试系统下的退出码系统。下表总结了 Testlib、eJudge 和 Contester 三种情况下对应的退出码系统,其中 eJudge 和 Contester 分别可以通过在编译时启用 -DEJUDGE 和 -DCONTESTER 开关实现。
| 判分结果 | Testlib | eJudge | Contester |
|---|---|---|---|
_ok |
0 |
0 |
0xAC |
_wa |
1 |
5 |
0xAB |
_pe |
2 |
4 |
0xAA |
_fail |
3 |
6 |
0xA3 |
_dirt |
4 |
6 |
- |
_points |
7 |
7 |
- |
_unexpected_eof |
8 |
- | - |
_partially 基 |
0(默认)或 50 |
- | - |
关于详细的 eJudge 退出码系统,请参考 eJudge 官方文档。
前面提到,在选手获得类型编号为 x 的部分正确结果时,需要通过 _pc(x) 设置结果(例如调用 quitf 时传入 _pc(x))。此时退出码会被设定为“_partially 基退出码与 x 的和”。如果需要同时支持 _partially 及其他判分结果,则无法仅通过退出码区分。此时可以在编译时启用 -DTESTSYS 开关,将基退出码设置为 50。
3 简易正则表达式
简易正则表达式相关类 pattern 的定义对应 Testlib 代码中第 679 行开始的部分,而实现对应 Testlib 代码中第 1296 行开始的部分。
在 Testlib 中,你可以使用 pattern 类创建一个简易版本的正则表达式,并用于对字符串进行匹配校验,或者通过正则表达式生成数据。
需要注意的是,实现完整的正则表达式需要较大的开销,因此 Testlib 对可用的功能和格式进行了限制。尽管如此,Testlib 对较长的正则表达式处理依然不够优秀,因此在使用时应当尽可能简化正则描述,并在合适的情况下预处理正则描述。
3.1 正则描述
pattern 需要接受一个正则描述,也就是包含了正则表达式定义的字符串,在进行预处理后进行后续的匹配校验和随机生成工作。你可以在后续的一些场景内直接使用正则描述代表一个 pattern,但这样需要在每次使用时重新处理。对应的,你也可以使用 pattern(string s) 预处理一个正则描述,从而在后续的操作中复用处理结果,减少处理时间。截至目前为止,pattern 类支持的特性包括:
-
基础单元:一个基础单元由单字符或字符集,以及可选的匹配次数约束组成。
- 若要匹配空格,或者在需要匹配某个单字符时发现其可能会被视为特殊标志(如
\,?,[,]等),则需要使用反斜杠转义,例如"\\?","\\\\"等。需要注意,"\\n"并不会匹配换行符,而依然只能匹配字符n。
特别地,Testlib 会忽略正则描述中字符集外的空格(如
pattern("NO SOLUTION")和pattern("NOSOLUTION")等价),因此在需要匹配单个空格时需要使用"\\ "。- 字符集使用
[]包裹,内部包含字符集的描述,匹配时会检查当前字符是否在字符集中。你可以使用-一次性将其左右两个字符之间的所有字符添加到字符集中。另外,在[后添加^,则表示将匹配不在后续描述的字符集中的字符,也即将字符集取反。例如: pattern("[abc]")可以匹配a,b或c;pattern("[a-zA-Z0-9]")可以匹配所有字母或数字字符;pattern("[+\\-*/]")可以匹配任意四则运算符号,这里需要使用\\-转义;pattern("[^0-9]")可以匹配数字字符之外的所有字符。- 匹配次数约束主要包含四种类型:
- 使用
{}包裹,格式为{a}或{a,b}。前者表示匹配恰好a次,而后者表示匹配a至b次; ?形式上等价于{0,1};*形式上等价于{0,2147483647};+形式上等价于{1,2147483647}。
例如:
pattern("[0-9a-fA-F]{0,16}")可以匹配一个长度不超过 16 的十六进制串,可以为空;pattern("-?")可以匹配-或空串;pattern("[a-z]+")可以匹配任意长度的非空小写字符串。
- 若要匹配空格,或者在需要匹配某个单字符时发现其可能会被视为特殊标志(如
- 复合单元:你可以将多个基础单元串接起来,形成一个复合单元。这个单元需要按顺序匹配里面的所有基础单元。同时,你可以使用括号进一步划分单元的层次。例如:
pattern("id-[0-2]")可以匹配id-0,id-1和id-2;pattern("-?[1-9][0-9]*")可以匹配所有非零整数对应的字符串;pattern("([1-9][0-9]*) [+\\-*/] ([1-9][0-9]*)")可以匹配两个正整数之间的四则运算对应的字符串。需要注意,尽管这里用空格将复合单元的组成部分分隔,但实际匹配的字符串不应存在空格。
- 选择单元:可以将多个复合单元使用
|串接起来,形成一个选择单元。这个单元将字符串与其中任意一个复合单元匹配。例如:pattern("Alice | Bob")可以匹配Alice或Bob;pattern("! | 0 | -?[1-9][0-9]*")可以匹配!或任意整数对应的字符串;pattern("(\\?\\ (0|[1-9][0-9]*)) | (!\\ (0|[1-9][0-9]*))")可以匹配形如? x或! x的字符串,其中x为一个非负整数。
- Testlib 对正则描述具有一个特殊的限制:对于一个复合单元,其组成部分除了最后一个外只能是基础单元,括号也只能出现在最后一个部分。例如,
pattern("12(3|4)")是合法的,但pattern("(1|2)(3|4)")和pattern("(1)23")都是不合法的。
::::info[为什么需要有上面的限制?]{open} 在这一限制下可以发现,在匹配过程中,选择单元之后不会存在其他的内容。换句话说,如果匹配时遇到了选择单元,那么这个选择单元必然会匹配字符串剩余的部分。
此时,只需要判断某个 pattern 能否匹配字符串的某个后缀,而不是某个子串。这会大大降低正则描述的处理难度。
::::
Testlib 在实际匹配过程中使用贪婪匹配机制,也就是尽可能多的匹配基础单元,直到到达匹配次数约束的上界。例如,pattern("[0-9]?1") 无法匹配字符串 1,尽管实际的正则表达式可以匹配。
在实际使用中,可以使用 p.matches(const string &str) 方法确定某个字符串是否符合正则描述。例如:
pattern p("[abc]*");
ensure(p.matches("abc"));
ensure(!p.matches("testlib"));
另外,在使用 InStream 读入一个字符串时,可以立刻检测这个字符串是否满足某个正则描述,具体方式会在“读入相关类”章节讨论。
3.2 随机生成规则
实际上,上述的案例描述已经详细介绍了 pattern 类的匹配校验形式。接下来将会介绍 pattern 的随机生成规则。
首先,字符集实际上是可重复的。在实际随机时,会从这个可重集合中等概率选择一个字符,因此字符被选中的概率与其出现次数成正比。例如,使用 pattern("[A-ZA-ZA-Za-z]") 生成随机字母时,大写字母的出现概率是小写字母的三倍。包含带 ^ 的字符集的正则描述不应当用于随机生成,因为这可能会产生不可见字符等。
其次,对于匹配次数约束,将会从所有可能的次数中等概率选择一个,并生成能够匹配对应次数的字符串。*禁止将包含 `或+` 的正则描述用于随机生成**。
最后,复合单元在随机生成时会将所有基础单元的随机生成结果按次序拼接,而选择单元则会等概率随机其中任何一个复合单元,并将其生成结果作为这个选择单元的生成结果。
你可以使用 p.next(random_t &rnd) 得到随机生成的结果。另外,rnd.next(string str) 和 rnd.next(const char *format, ...) 可以使用正则描述进行随机生成,不过在每次调用时都需要重新处理正则描述。
::::info[pattern 类的内部是怎么实现的?]
可以在第 716 行查看 pattern 类的定义:
class pattern {
public:
/* Create pattern instance by string. */
pattern(std::string s);
/* Generate new string by pattern and given random_t. */
std::string next(random_t &rnd) const;
/* Checks if given string match the pattern. */
bool matches(const std::string &s) const;
/* Returns source string of the pattern. */
std::string src() const;
private:
bool matches(const std::string &s, size_t pos) const;
std::string s;
std::vector<pattern> children;
std::vector<char> chars;
int from;
int to;
};
可以看到,pattern 类包含了如下成员变量:
std::string s为这个pattern对应的正则描述,可以使用p.src()的方式得到;- 如果当前
pattern对应一个复合或基础单元,则:std::vector<char> chars代表第一个基本单元的字符集;int from和int to分别代表第一个基本单元的匹配次数下界和上界;std::vector<pattern> children代表后续的复合或基础单元。
- 如果当前
pattern对应一个选择单元,则:std::vector<pattern> children代表其包含的所有复合单元;- 其余成员变量不储存信息。
在实际匹配时,使用递归的方法,确定当前的正则描述属于哪一类单元,并使用正则描述的子串构造更小的 pattern。在匹配和随机生成时,也只需要递归处理需求即可。
::::
不难注意到,pattern 类的实现并不能支撑我们完成更复杂的正则表达式需求,不过对于一般的出题环境而言已经足够了。另外,也可以通过 InStream 创建的类 stringstream 结构读取匹配成功的字符串,从而借助 InStream 读入函数的检查进一步简化正则描述(例如在读取非负整数时,只需要读入仅包含数字字符的字符串,从而省去了对选择单元的需求)。
4 随机类
随机类 random_t 的定义和实现对应 Testlib 代码中第 740 行开始的部分。
Testlib 为了保证 Generator 在任意编译和运行环境下能够得到一致的结果,在其内部实现了随机类 random_t,并定义了一个变量 rnd 用于实际调用。
在初始化 Generator 时,Testlib 会根据命令行参数(也就是 argv[] 的值)初始化一个随机种子,并将其用于后续的随机流程中。需要注意的是,只有 Generator 环境会自动设置随机种子,若要在其他环境固定随机种子,请使用 rnd.setSeed(long long _seed) 函数。
第一章关于 Generator 的介绍中提到,Generator 在使用之前,需要使用 registerGen(argc, argv, 1) 语句初始化。这里的参数 1 实际上代表了使用的随机版本。如果这个参数为 0,则表示使用旧版随机,为 1 则表示使用新版随机。
::::info[Testlib 随机的基础是什么?两个版本的随机有何不同?]
在初始化 random_t 类时,随机种子被默认设置成 3905348978240129619LL。在 random_t 类的实现中,可以使用 setSeed 设置随机种子,对应的代码如下所示:
/* Sets seed by command line. */
void setSeed(int argc, char *argv[]) {
random_t p;
seed = 3905348978240129619LL;
for (int i = 1; i < argc; i++) {
std::size_t le = std::strlen(argv[i]);
for (std::size_t j = 0; j < le; j++)
seed = seed * multiplier + (unsigned int) (argv[i][j]) + addend;
seed += multiplier / addend;
}
seed = seed & mask;
}
/* Sets seed by given value. */
void setSeed(long long _seed) {
seed = (unsigned long long) _seed;
seed = (seed ^ multiplier) & mask;
}
可以看到 setSeed 存在两个重载,其中第一个重载通过命令行参数计算随机种子的,其主要通过哈希算法处理所有命令行参数(不包含第一个参数,也就是可执行文件的名称),并通过特殊值标记每个命令行参数的末尾(末尾字符累加的值为 addend + multiplier / addend,其余字符累加的值为 addend);第二个重载则仅通过传入值设置种子。上述算法使用的三个常数值通过如下代码定义:
const unsigned long long random_t::multiplier = 0x5DEECE66DLL;
const unsigned long long random_t::addend = 0xBLL;
const unsigned long long random_t::mask = (1LL << 48) - 1;
此时,seed 维护了一个 48 位的伪随机数,而一次变换可以使用如下代码表示:
seed = (seed * multiplier + addend) & mask;
这会产生一个新的 48 位伪随机数。random_t 类中所有随机生成函数的基础都是 nextBits 函数,它接受一个不超过 63 的整数 bits,并返回包含 bits 位的随机数。
long long nextBits(int bits) {
if (bits <= 48) {
seed = (seed * multiplier + addend) & mask;
return (long long) (seed >> (48 - bits));
} else {
if (bits > 63)
__testlib_fail("random_t::nextBits(int bits): n must be less than 64");
int lowerBitCount = (random_t::version == 0 ? 31 : 32);
long long left = (nextBits(31) << 32);
long long right = nextBits(lowerBitCount);
return left ^ right;
}
}
这个代码在 bits 不超过 48 时直接取 seed 的高位,否则需要通过两次随机产生。可以看到,旧版随机会导致结果的二进制第 31 位固定为 0,从而导致一些预期之外的情况,因此推荐使用新版随机。
::::
4.1 随机整数
你可以使用 rnd.next() 函数产生随机的整数。
next() 的重载 |
功能 | 要求 |
|---|---|---|
int next(n) |
产生一个 |
|
long next(n) |
^ | ^ |
long long next(n) |
^ | ^ |
unsigned int next(n) |
^ | |
unsigned long next(n) |
^ | ^ |
unsigned long long next(n) |
^ | |
int next(from, to) |
产生一个 |
|
unsigned int next(from, to) |
^ | ^ |
long next(long from, long to) |
^ | |
unsigned long next(from, to) |
^ | |
long long next(from, to) |
^ | |
unsigned long long next(from, to) |
^ |
注:上表中参数类型和返回值类型相同。
由于未知原因,其中的一些函数没有设置
::::info[Testlib 如何产生随机整数?]
Testlib 使用接受-拒绝方法生成随机整数。例如,以下是 long long next(long long n) 的实现代码:
/* Random value in range [0, n-1]. */
long long next(long long n) {
if (n <= 0)
__testlib_fail("random_t::next(long long n): n must be positive");
const long long limit = __TESTLIB_LONGLONG_MAX / n * n;
long long bits;
do {
bits = nextBits(63);
} while (bits >= limit);
return bits % n;
}
可以看到,算法选出了尽可能大的整数 limit,使得它是 n 的倍数。随后,不断在 limit 时,对 n 取模的结果就是随机结果。
不难发现,上述算法在任意情况下期望不超过两次随机就能得到结果,并且只要 nextBits() 的结果足够随机,
4.2 随机浮点数
你可以使用 rnd.next() 函数产生随机的浮点数。
| :::align{center} | next() 的重载 |
功能 | 要求 |
|---|---|---|---|
double next() |
产生一个 |
无 | |
double next(n) |
产生一个 |
||
double next(from, to) |
产生一个 |
:::
注:上表中参数类型均为 double。
::::info[Testlib 如何产生随机浮点数?]
以下是 double next() 的实现代码:
/* Random double value in range [0, 1). */
double next() {
long long left = ((long long) (nextBits(26)) << 27);
long long right = nextBits(27);
return __testlib_crop((double) (left + right) / (double) (1LL << 53), 0.0, 1.0);
}
可以看到,Testlib 分两部分随机出一个 53 比特的数字,并将其除以 double 的尾数部分包含 52 位,可以表示 53 位的精度,故其恰好能精确储存通过上述方式计算得到的结果。
这里需要注意,为了避免随机序列的生成方式对结果的某些比特产生可被利用的统计学特性,这里在分组生成时采用了均分的方案,而不是通过 nextBits(63) 生成。
剩余的随机函数实现仅在这个函数的基础上进行放缩和偏置,这里不再展开。 ::::
4.3 容器内、迭代器间随机
在 Testlib 中,可以使用 rnd.any(container) 和 rnd.any(begin, end) 获取一个非空的可枚举容器或迭代器区间(包含 begin 但不包含 end,即左闭右开)内部的任意元素。它们的运作流程如下:
- 对于
rnd.any(container)而言,使用size()成员函数得到容器大小,否则通过distance函数计算得到迭代器区间的长度; - 使用
next(int n)函数计算需要选择的元素下标; - 从容器或区间的起始迭代器开始,使用
advance函数移动到需要选择的元素上,返回指向的元素。
可以发现,上述流程中 rnd.any(container) 调用了容器的 size() 函数和常值迭代器的 advance 函数,而 rnd.any(begin, end) 调用了迭代器的 distance 和 advance 函数。因此,在使用上述函数之前,需要事先确认调用的时间复杂度。例如,随机选取 set 内部元素的时间复杂度为 vector 或数组内部元素的时间复杂度总是
4.4 带偏随机
有时,我们希望随机分布不仅仅是简单的均匀随机,而是具有一定“倾向”的随机分布。Testlib 的 rnd.wnext() 和 rnd.wany() 函数可以产生带偏的随机结果,使其有更大的概率落在较大或较小的值上。和 rnd.any() 类似,rnd.wany() 内部调用 wnext() 函数得到元素下标,因此功能上和 rnd.wnext() 函数类似。下文主要介绍 rnd.wnext() 函数。
rnd.wnext(n, int type) 可以用于产生整数和浮点数,如 int rnd.wnext(int n, int type)。它在内部仅会调用单参数的 rnd.next(n),因此也需要满足单参数随机数的限制。
Testlib 实现了 rnd.wnext(n, int type) 的各种重载。其中,函数的第一个参数 n 的类型决定了返回值的类型,而第二个参数 type 表示偏置的类型。具体的:
- 如果
type = 0,则返回结果即为rnd.next(n); - 如果
type > 0,则结果为1 + type次rnd.next(n)结果的最大值,结果偏大; - 如果
type < 0,则结果为1 - type次rnd.next(n)结果的最小值,结果偏小。
另外,在 type 的绝对值不小于 random_t 内部设置的限制(默认为 25)时,会使用特殊的算法得到随机结果,以保证在有限的调用次数下得到概率上足够相近的随机分布。因此,你无需考虑 type 的绝对值过大导致内部随机次数过多的情况出现。
::::info[Testlib 如何处理 type 的绝对值较大的情况?]
不妨记 type,且此时
其中
因此,变量
因此
Testlib 使用上述方法处理 type 的绝对值过大的情况。例如,int wnext(int n, int type) 函数的实现如下:
int wnext(int n, int type) {
if (n <= 0)
__testlib_fail("random_t::wnext(int n, int type): n must be positive");
if (abs(type) < random_t::lim) {
int result = next(n);
for (int i = 0; i < +type; i++)
result = __testlib_max(result, next(n));
for (int i = 0; i < -type; i++)
result = __testlib_min(result, next(n));
return result;
} else {
double p;
if (type > 0)
p = std::pow(next() + 0.0, 1.0 / (type + 1));
else
p = 1 - std::pow(next() + 0.0, 1.0 / (-type + 1));
return __testlib_crop((int) (double(n) * p), 0, n);
}
}
::::
和 rnd.next() 类似,rnd.wnext() 也有三种重载:
double rnd.wnext(int type),其等价于调用rnd.wnext(1.0, type);rnd.wnext(n, int type),其对应的限制和返回值范围和rnd.next(n)相同;rnd.wnext(from, to, int type),其对应的限制和返回值范围和rnd.next(from, to)相同。
4.5 其他可用的随机函数
除了上面介绍的函数之外,random_t 类其余可用的随机函数如下表所示。出于篇幅原因,函数的要求在此略去。
:::align{center}
| 函数 | 功能 |
|---|---|
string next(const string &ptrn) |
以 ptrn 为正则描述生成随机字符串 |
string next(const char *format, ...) |
使用格式化结果作为正则描述生成随机字符串 |
vector<E> perm(T size, E first) |
生成一个随机排列,其最小元素为 first |
vector<T> perm(T size) |
等价于 perm(size, T(0)) |
vector<T> distinct(int size, T from, T to) |
等概率生成 size 个在 [from, to] 范围内且互不相同的元素 |
vector<T> distinct(int size, T upper) |
等价于 distinct(size, T(0), upper - 1) |
vector<T> partition(int size, T sum, T min_part) |
等概率给出将 sum 拆分为 size 个不小于 min_part 的数字之和的方案,内部返回 distinct 结果的差分 |
vector<T> partition(int size, T sum) |
等价于 partition(size, sum, T(1)) |
:::
5 读入相关类
所有读入相关类的定义对应 Testlib 代码中第 1670 行开始的部分,而实现对应 Testlib 代码中第 2832 行开始的部分。
Testlib 专门为字符串和文件读取实现了 InStream 类。该类提供统一接口,内部会调用单字符读入类,从而将用户侧读取逻辑与底层字符串/文件读取逻辑分离。本章主要介绍如何在用户侧创建并使用 InStream;字符串或文件读取的底层细节详见本章最后的技术分析。
在 Checker、Validator 和 Interactor 中,可以使用 Testlib 创建好的 InStream 类对数据文件和选手程序输出进行读取。它们分别为 inf、ouf 和 ans。需要明确的是,它们除了绑定不同的流之外,还具有不同的模式。具体而言,Testlib 具有三种读入类模式:
enum TMode {
_input, _output, _answer
};
在实际情况中,inf、ouf 和 ans 的模式分别为 _input、_output 和 _answer。尽管模式并不会直接影响后续读取内容的流程,但它们标记了 InStream 类发生读取错误时代表的情况。例如,一个 _output 模式类在读取错误时,会认为选手程序输出错误,判分结果为 _wa;否则,一个 _input 或 _answer 模式类在读取错误时,会认为数据出现问题,判分结果为 _fail。
另外,Validator 中 inf 变量会自动进入严格模式,此时需要满足如下的额外需求:
- 不自动跳过任何空白字符,需要手动通过
readSpace()、readEoln()和readEof()等函数读取空白字符。在应当读取非空白字符却遇到空白字符时会以_pe结果退出; - 必须显式调用至少一次
inf.readEof(),从而明确输入数据文件已被读取完毕; - 除了行批读入以换行符分隔之外,所有批读入都需要以恰好一个空格分隔;
- 对换行符的读取有更严格的限定,基于运行平台和目标平台而定,具体情况详见“基础读取”小节。
一般来说,严格模式的限制会作用于所有读取函数,而除去空白字符处理之外,一些特殊的行为会单独列出介绍。
下面将会介绍 InStream 可以调用的函数,不过出于后续篇幅需要,这里提前给出读入函数可能支持的四种特性:
- P(Pattern Restriction,格式限定):代表可以追加
const pattern &p或const std::string &ptrn参数,从而限定字符串需要满足的正则描述,仅存在于字符串读取函数中,如readLine("[0-9]+")。在读取的字符串与正则描述不匹配时,程序将因此退出并给出合理的判分结果; - R(Range Restriction,范围限定):代表可以追加
minv和maxv参数,从而限定读取值的范围,如readInt(1, 1e9)。在读取的变量值超出这个范围时,程序将因此退出并给出合理的判分结果; - V(Variable Name,变量名):代表在存在格式或范围限定的前提下,可以进一步追加
variableName参数,从而设置读取失败时显示的变量名,如readInt(1, 1e9, "n")。这些变量名会被用于提供更丰富的提示信息,而在 Validator 中通过inf指定的变量名还会进一步用于生成日志总览文件; - I(Index Base,索引基值):代表可以追加
indexBase参数,表示在读入错误时给出的提示信息中第一个元素对应的索引。这个特性仅存在于批读入函数中,如readLines(n, 1)和readInts(n, 1, 1e9, "a", 1)。需要注意,在同时使用 格式或范围限定 和 索引基值 时,变量名不可省略,如readInts(n, 1, 1e9, 1)无法通过编译。indexBase参数在默认情况下的值是 1。
上面的参数追加需要按照顺序进行,无法打乱。你可以通过查看读入函数的重载进一步确认可用的参数组合。
需要注意的是,变量名同时会用于 Validator 生成的日志总览文件中。这个文件会在范围限定下显示某一个变量是否取到下界或上界。你可以使用 ~n 变量名取消下界检查,以此类推。详细信息详见第六章“Validator 规范”。
5.1 初始化
除了已有的 inf、ouf 和 ans 之外,你应当只使用如下构造函数创建新的 InStream 类:
InStream(const InStream &baseStream, std::string content);
这个构造函数会继承 baseStream 的模式和严格参数,并以 content 为内容创建一个 InStream 变量。你可以使用该变量进行读取,例如第一章 Interactor 的示例:
string cur = ouf.readToken("!|[1-9][0-9]{0,8}");
int last;
if (cur != "!") {
InStream tmp(ouf, cur);
last = tmp.readInt(-INF, INF);
queries++;
} else {
is_answer = true;
last = ouf.readInt(-INF, INF);
}
这段代码在读取字符串对应为整数时以 ouf 为基创建了一个 InStream 类变量,并将读取字符串作为内容,随后就可以使用后面将会介绍的整数读取函数读出整数值。
另外,对于所有 InStream 类变量而言,有三个可以修改的参数:
maxFileSize,表示在变量绑定到某个文件时,这个文件的大小最大值,单位为B,默认值为128 * 1024 * 1024,即 128 MB;maxTokenLength,表示读取单字符串(如readWord()或readToken())时,结果长度的最大值,单位为B,默认值为32 * 1024 * 1024,即 32 MB;maxMessageLength,表示在通过InStream类变量内部的退出函数(如ouf.quit(result, msg))退出时,提示消息长度的最大值,若超出则会自动进行截断,单位为B,默认为32000,即 32 KiB。
如果需要对 inf、ouf 或 ans 的参数进行修改,请务必通过类似 inf.maxFileSize = 256 * 1024 * 1024 的形式,在 Testlib 初始化函数(如 registerTestlibCmd(argc, argv))之前进行修改。
5.2 基础读取
以下是 InStream 类可以使用的基础读取函数:
| :::align{center} | 函数 | 功能 |
|---|---|---|
void skipBlanks() |
不断跳过空白字符,直到遇到非空白字符或 EOF | |
char curChar() |
获得等待被读取的字符,但不将它从流中取出 | |
void skipChar() |
从流中取出一个字符 | |
char nextChar() |
获得等待被读取的字符,并将其从流中取出 | |
char readChar() |
和 nextChar() 功能相同,但更符合语义 |
|
char readChar(char c) |
在 readChar() 的基础上检查被读取的字符是否为 c |
|
char readSpace() |
等价于 readChar(' ') |
|
char unreadChar(char c) |
将字符 c 放回流中 |
|
bool eof() |
检查流是否为空 | |
bool seekEof() |
等价于调用 skipBlanks() 后返回 eof() 的结果 |
|
bool eoln() |
检查流的开始是否为换行符,若为换行符则自动读取 | |
bool seekEoln() |
等价于调用 skipBlanks() 后返回 eoln() 的结果 |
|
void nextLine() |
不断取出字符,直到到达下一行的第一个字符或遇到 EOF |
:::
这里需要额外介绍 eoln() 的行为。在非严格模式下,eoln() 函数会同时检查流的开头是否为 #13#10(即 CRLF,主要用于 Windows 系统)、#10(即 LF,主要用于 Unix、Linux 和现代 macOS 系统)或 EOF。而在严格模式下,eoln() 的形式会受到如下宏的影响:
ON_WINDOWS:表示当前的运行平台是否为 Windows,由 Testlib 自行定义;FOR_WINDOWS:表示题目配置的目标平台为 Windows,由编译开关(如-DFOR_WINDOWS)指定;FOR_LINUX:表示题目配置的目标平台为 Linux 类型的平台,由编译开关(如-DFOR_LINUX)指定。
此时,在宏满足下面两种状态之一时,则仅检查流的开头是否为 #13#10,否则仅检查流的开头是否为 #10:
- 题目配置的目标平台为 Windows;
- 当前的运行平台为 Windows,且不通过
FOR_LINUX宏指定题目配置的目标平台为 Linux 类型的平台。
5.3 单字符串读取
在 Testlib 中,可以通过如下函数读取单字符串:
| :::align{center} | 函数 | 功能 | 特性 |
|---|---|---|---|
string readWord() |
读取一段非空白字符 | PV | |
string readToken() |
^ | ^ | |
string readWordTo(string &result) |
读取一段非空白字符,将结果写入 result 变量 |
^ | |
string readTokenTo(string &result) |
^ | ^ | |
vector<string> readWords(int size) |
读取 size 段非空白字符,在严格模式下必须使用单个空格分隔 |
PVI | |
vector<string> readTokens(int size) |
^ | ^ |
:::
在尝试读取字符串却遇到 EOF 时,Testlib 会在内部以 _unexpected_eof 结果退出。
5.4 整数与浮点数读取
在 Testlib 中,可以通过如下函数读取整数和浮点数:
| :::align{center} | 函数 | 功能 | 特性 |
|---|---|---|---|
int readInt() |
读取一段非空白字符,并转换为 int 范围内的整数 |
RV | |
int readInteger() |
^ | ^ | |
long long readLong() |
读取一段非空白字符,并转换为 long long 范围内的整数 |
^ | |
unsigned long long readUnsignedLong() |
读取一段非空白字符,并转换为 unsigned long long 范围内的整数 |
^ | |
double readReal() |
读取一段非空白字符,并转换为 double 范围内的浮点数,支持科学计数法 |
^ | |
double readDouble() |
^ | ^ | |
int readInts(int size) |
读取 size 段非空白字符,并转换为 int 范围内的整数 |
RVI | |
int readIntegers(int size) |
^ | ^ | |
long long readLongs(int size) |
读取 size 段非空白字符,并转换为 long long 范围内的整数 |
^ | |
unsigned long long readUnsignedLongs(int size) |
读取 size 段非空白字符,并转换为 unsigned long long 范围内的整数 |
^ | |
double readReals(int size) |
读取 size 段非空白字符,并转换为 double 范围内的浮点数,支持科学计数法 |
^ | |
double readDoubles(int size) |
^ | ^ |
:::
需要注意,上述函数在读取浮点数时允许科学计数法。如果你不希望支持科学计数法,则可以通过如下函数读取浮点数:
| :::align{center} | 函数 | 功能 | 特性 |
|---|---|---|---|
double readStrictReal(double minv, double maxv, int minAfterPointDigitCount, int maxAfterPointDigitCount) |
读取一段非空白字符,并转换为 double 范围内的严格浮点数 |
V | |
double readStrictDouble(double minv, double maxv, int minAfterPointDigitCount, int maxAfterPointDigitCount) |
^ | ^ | |
vector<double> readStrictReals(int size, double minv, double maxv, int minAfterPointDigitCount, int maxAfterPointDigitCount) |
读取 size 段非空白字符,并转换为 double 范围内的严格浮点数 |
VI | |
vector<double> readStrictDoubles(int size, double minv, double maxv, int minAfterPointDigitCount, int maxAfterPointDigitCount) |
^ | ^ |
:::
在上述函数中,严格浮点数需要满足如下要求:
- 值在
[minv, maxv]范围内; - 表示中小数点后的位数在
[minAfterPointDigitCount, maxAfterPointDigitCount]范围内(若表示中没有小数点,则小数点后的位数视为 0); - 使用定点格式,不支持科学计数法。
5.5 行读取
在 Testlib 中,可以通过如下函数读取一行字符串:
| :::align{center} | 函数 | 功能 | 特性 |
|---|---|---|---|
string readString() |
读取一行字符 | PV | |
string readLine() |
^ | ^ | |
void readStringTo(string &result) |
读取一行字符,将结果写入 result |
^ | |
void readLineTo(string &result) |
^ | ^ | |
vector<string> readStrings(int size) |
连续读取 size 行字符 |
PVI | |
vector<string> readLines(int size) |
^ | ^ |
:::
以下是在读取一行字符时需要注意的事情:
- 在读取行时,不会自动跳过空行,因此对于非严格模式,在读取行前建议通过
eoln()函数处理上一行可能未处理的换行符; - 读取完毕后会自动从流中取出换行符,而读取的结果不包含换行符;
- 在严格模式下,读取完一行字符后必须紧跟一个换行符,推荐在造数据时额外添加文末回车;
- 在非严格模式下,如果尝试读取时就遇到了 EOF,此时不会报错,且结果为空串。
5.6 其他可用的函数
除了前面的读入相关函数之外,InStream 类还支持如下方法:
| :::align{center} | 函数 | 功能 |
|---|---|---|
void readEoln() |
在严格模式中使用,尝试读取一个换行符,在失败时报错 | |
void readEof() |
在严格模式中使用,尝试读取一个 EOF,在失败时报错 | |
void quit(TResult result, const char *msg) |
以当前流模式调用退出函数,具体行为详见第八章“退出函数和断言函数” | |
void quitf(TResult result, const char *msg, ...) |
^ | |
void quitif(bool condition, TResult result, const char *msg, ...) |
^ | |
void quits(TResult result, std::string msg) |
^ | |
void ensuref(bool cond, const char *format, ...) |
^ | |
void close() |
关闭流,一般不需要使用 |
:::
::::info[Testlib 如何管理读入类?]
目前,Testlib 封装了三个单字符读入类。这三个类都以 InputStreamReader 作为基类,其定义与对应的功能如下:
class InputStreamReader {
public:
// 仅用于 Validator,设置测试点编号,用于读取异常时在提示信息提供测试点编号
virtual void setTestCase(int testCase) = 0;
// 获取已经读取的字符(unreadChar 会弹出其中的字符)
virtual std::vector<int> getReadChars() = 0;
// 获取当前待读取的字符,但不从流中取出
virtual int curChar() = 0;
// 获取当前待读取的字符,并从流中取出
virtual int nextChar() = 0;
// 从流中取出一个字符
virtual void skipChar() = 0;
// 将 c 放回流的开始
virtual void unreadChar(int c) = 0;
// 得到 Reader 的名称
virtual std::string getName() = 0;
// 判定文件是否读取到 EOF
virtual bool eof() = 0;
// 关闭 Reader
virtual void close() = 0;
// 获取读取到的行号,下标从 1 开始
virtual int getLine() = 0;
// 析构函数
virtual ~InputStreamReader() = 0;
};
从上面的类可以派生出如下三个类:
StringInputStreamReader:以字符串为内容的流结构,内部包含字符串和一个指向当前字符的指针;FileInputStreamReader:以FILE*为内容的在线流结构,内部包含FILE*类文件信息,操作通过标准库函数实现;BufferedFileInputStreamReader:以FILE*为内容的离线流结构,内部包含FILE*类文件信息和一个大小为 2 MiB 的缓冲区,在缓冲区读空时会从文件中读取 1 MiB 大小的信息并放置在缓冲区的后半部分,从而支持放回流操作等。
为了实现多态,在 InStream 内部包含一个 InputStreamReader* 指针,所有读取方法都会通过该指针指向的成员函数间接读取字符串或文件流。在 Testlib 初始化时,所有与标准流(如 stdin、stdout 和 stderr)绑定的 InStream 类内部使用 FileInputStreamReader 管理,而所有与文件绑定的 InStream 类内部使用 BufferedFileInputStreamReader 管理。在运行过程中通过构造函数创建的 InStream 类内部使用 StringInputStreamReader 管理。
::::
6 Validator 规范
本章节对应 Testlib 代码中第 2389 行开始的部分。
从“基础介绍”章节中提到,Validator 可以向日志总览文件、标记文件和单测试数据文件写入信息。实际上,Validator 不仅启用了严格读入模式,还实时追踪了变量信息和测试数据信息。本章节主要通过介绍上述三个文件的格式以及对应的操纵逻辑展开。
6.1 日志总览文件
日志总览文件按顺序包含如下信息:
- 变量触及上下界与否;
- 特性信息;
- 变量的固定上下界;
- 所有变量的名称。
在使用 InStream 读入时追加的变量名和范围限定会被 Validator 追踪。Validator 最多跟踪 255 个变量的信息,而对于变量名相同的数字型变量,Validator 会追踪其每次读入时设置的上下界是否固定,并记录其是否在某次读入时触及上界或下界。具体地,认为变量在读入时触及上界或下界,当且仅当读取结果和上界或下界的值相同(对于浮点数而言则是相差不超过
如果想要让 Validator 不追踪某个变量是否触及上下界,可以在变量名左右添加 ~,如 ~t 表示不追踪变量 t 是否触及下界(在日志总览文件中总是报告其触及下界),而 ~n~ 表示不追踪变量 n 是否触及上界和下界。
除此之外,你可以在 Validator 内通过一定的代码逻辑判断输入数据文件是否满足某些特性(如输入的图是否为链或菊花等)。你需要先通过 addFeature(feature) 函数注册一个特性,然后在该特性得到满足时通过 feature(feature) 函数标记。
日志总览文件的第一部分,即“变量触及上下界与否”部分的格式如下:
"a": min-value-hit max-value-hit
"b":
"c": min-value-hit
"d": max-value-hit
上述内容表示数字型变量 a 在读取时曾触及过上界与下界,b 在读取时未曾触及过上界或下界,c 在读取时曾触及过下界,而 d 在读取时曾触及过上界。
日志总览文件的第二部分,即“特性信息”部分的格式如下:
feature "a_tree": hit
feature "chain": hit
feature "daisy":
上述内容表示 Validator 共注册了三个特性:a_tree、chain 和 daisy,而只有前两个特性得到满足。形式上来讲,这可能说明读入的图是一个链,但不是菊花图。
日志总览文件的第三部分,即“变量的固定上下界”部分的格式如下:
constant-bounds "a": 1 100
constant-bounds "b": 1 ?
constant-bounds "c": ? ?
constant-bounds "d": ? ?
上述内容表示数字型变量 a 在读入时设置的下界固定为 1、上界固定为 100;b 的下界固定为 1,而上界不是固定的;c 和 d 的上界和下界都不是固定的。
日志总览文件的最后一部分,即“所有变量的名称”部分的格式如下:
variable "a"
variable "b"
variable "c"
variable "d"
variable "mode"
variable "query"
上述内容表示在读入时曾声明过的变量名为 a、b、c、d、mode 和 query。需要注意的是,这里的变量名不仅包括数字型变量,还包括字符串型变量。
6.2 标记文件
标记文件以 "MU\xF3\x01" 作为头信息,而剩余部分包含了读入数据文件的内容与每一组测试数据的起始位置。在读入过程中,你可以使用 setTestCase(int testCase) 函数标记某一组测试数据的起始位置。此时,Testlib 将会在标记文件中写入一个特殊结构,如 !c1; 表示此处为第 1 组测试数据的开始。为了将特殊结构和读入数据文件的内容区分,输入数据文件中的 \ 和 ! 将会被转义为 \\ 和 \!。
以下是标记文件可能的结构,其中 MU?? 代表 "MU\xF3\x01"。
MU??3
!c1;1 2
!c2;escaped \\"abc\\"
!c3;Hello World\!
其对应的读入数据文件为:
3
1 2
escaped \"abc\"
Hello World!
::::error[需要注意!]{open}
在 Testlib 中,测试数据编号必须从 1 开始。如果你在第一次调用 setTestCase(int testCase) 时将编号设置为 0,那么 Testlib 会认为你的测试数据编号从 0 开始,并强制将你设置的所有编号值加上 1。这一操作会影响标记文件给出的测试数据编号,以及接下来的单测试数据文件的结果。因此,在非必要情况下,请在设置测试数据编号时保证从 1 开始编号。
::::
6.3 单测试数据文件
在 Validator 中,你可以使用 --testCase testCase 参数和 validator.setTestCase(int testCase) 函数设置一个特定的测试数据编号。在程序结束时,Testlib 会扫描上述标记文件的结构,找到这个测试数据编号对应的输入数据,并输出到单测试数据文件中。和标记文件不同,单测试数据文件的内容不会被转义。
7 结束时检查
本章节对应 Testlib 代码中第 2777 行开始的部分。
在 Testlib 退出时,会检查如下限制是否被满足:
- 代码必须使用
register系列函数初始化; - Checker 必须调用
quit系列函数退出; - Validator 必须调用
inf.readEof()或quit系列函数; - Generator 在特定情况下不应存在未被使用的命令行参数,具体要求详见第十章“命令行参数解析”。
你可以通过 disableFinalizeGuard() 语句取消结束时检查。
::::info[Testlib 如何实现结束时检查?]
Testlib 创建了 TestlibFinalizeGuard 类的一个实例,并通过这个类的析构函数实现结束时检查。这个类的实现如下:
struct TestlibFinalizeGuard {
static bool alive;
static bool registered;
int quitCount, readEofCount;
TestlibFinalizeGuard() : quitCount(0), readEofCount(0) {
// No operations.
}
~TestlibFinalizeGuard() {
bool _alive = alive;
alive = false;
if (_alive) {
if (testlibMode == _checker && quitCount == 0)
__testlib_fail("Checker must end with quit or quitf call.");
if (testlibMode == _validator && readEofCount == 0 && quitCount == 0)
__testlib_fail("Validator must end with readEof call.");
/* opts */
autoEnsureNoUnusedOpts();
if (!registered)
__testlib_fail("Call register-function in the first line of the main (registerTestlibCmd or other similar)");
}
if (__testlib_exitCode == 0) {
validator.writeTestOverviewLog();
validator.writeTestMarkup();
validator.writeTestCase();
}
}
private:
/* opts */
void autoEnsureNoUnusedOpts();
};
可以发现这个类在析构时执行的功能和本章提到的限制相符。除此之外,这个类也实现了 Validator 的文件写入功能。
::::
8 退出函数和断言函数
本章节对应 Testlib 代码中第 2904 行、第 4425 行开始的部分。需要注意的是,InStream 类的实现包含了这一章节的内容,具体原因详见第一章“基础函数”部分。
在 Testlib 中,可以使用退出函数和断言函数,在满足某种条件时立刻退出程序并给出相应的判分结果。
8.1 判分结果
判分结果应当为 TResult 枚举类的元素,其所有可能情况如下:
| 标记 | 值 | 含义 |
|---|---|---|
_ok |
0 | 答案正确 |
_wa |
1 | 答案错误 |
_pe |
2 | 格式错误 |
_fail |
3 | 评测失败 |
_dirt |
4 | 存在多余输出 |
_points |
5 | 部分分值/部分分信息 |
_unexpected_eof |
8 | 意外到达文件结尾 |
_partially |
16 | 部分正确类型基值 |
在实际调用时,需要注意如下几点:
- 在选手程序仅获得部分分值或部分分信息时,应当通过
quitp系列函数设置; _partially不应直接使用。应使用_pc(x)设置部分正确类型编号x,其中_pc(x) = _partially + x,对应退出码为“_partially基退出码与x的和”;_unexpected_eof会被自动转化为_pe,如果需要取消这一特性,请在编译时启用-DENABLE_UNEXPECTED_EOF开关。
8.2 退出函数
在 Testlib 中,可以使用如下退出函数:
| :::align{center} | 函数或方法 | 功能 |
|---|---|---|
void quit(TResult result, const char *msg)(全局) |
以选手程序身份给出 result 作为判分结果,msg 作为提示文本 |
|
void quitf(TResult result, const char *msg, ...)(全局) |
与 quit 类似,但使用格式化结果作为提示文本 |
|
void quitif(bool condition, TResult result, const char *msg, ...)(全局) |
与 quit 类似,但仅在 condition 为 false 时退出 |
|
void quits(TResult result, string msg) |
与 quit 类似 |
|
void quitp(T points, const std::string &message = "")(全局) |
以选手程序身份给出 _points 作为判分结果,points 作为部分分值,message 作为提示文本,其中 T 可以为 float、double、long double 或 int |
|
void quitpi(const string &points_info, const string &message = "")(全局) |
以选手程序身份给出 _points 作为判分结果,points_info 作为部分分信息,message 作为提示文本,其中 points_info 不应包含空格 |
|
void InStream::quit(TResult result, const char *msg) |
以 InStream 的身份给出 result 作为判分结果,msg 作为提示文本 |
|
void InStream::quitf(TResult result, const char *msg, ...) |
与 InStream::quit 类似,但使用格式化结果作为提示文本 |
|
void InStream::quitif(bool condition, TResult result, const char *msg, ...) |
与 InStream::quit 类似,但仅在 condition 为 false 时退出 |
|
void InStream::quits(TResult result, string msg) |
与 InStream::quit 类似 |
:::
需要注意的是,quitpi 函数中给出的 points_info 信息仅会被追加到提示文本的开头,跟在 points_info= 之后,如 points_info=some_points_info You earned some points :),而 quitp 函数中给出的 points 信息不仅会被直接追加到提示信息的开头,并且会在 appes 模式中作为属性值单独列出。
8.3 断言函数
断言函数除了可以被用户调用之外,也用于读入函数内部。在 Testlib 中,可以使用如下断言函数:
| :::align{center} | 函数或方法 | 功能 |
|---|---|---|
void ensure(bool cond)(全局) |
在 cond 为 false 时给出 _fail 作为判分结果 |
|
void ensuref(bool cond, const char *format, ...)(全局) |
与 ensure 类似,但可以自定义提示信息 |
|
void InStream::ensure(bool cond) |
在 cond 为 false 时调用 quit 成员函数并给出 _wa 作为判分结果 |
|
void InStream::ensuref(bool cond, const char *format, ...) |
与 InStream::ensure 类似,但可以自定义提示信息 |
:::
需要注意的是,InStream 内部实现的 ensure 成员函数默认给出 _wa 作为判分结果,而这一判分结果可能会根据 InStream 的身份转化为 _fail。
8.4 退出时身份
在“读入相关类”章节中提到,全局实现中的退出函数内部会直接调用 InStream 的退出函数。因此,在讨论退出函数时,需要引入“身份”这一概念,即谁导致了本次退出。具体而言:
- 在使用某一个
InStream读取内容却发生错误,给出_wa、_pe等作为判分结果时,身份就是InStream的模式,也即数据配置(inf和ans)和选手程序(ouf)之一。这一性质同样适用于InStream中的quit成员函数和ensure成员函数; - 在使用全局的
quit系列函数时,身份默认为选手程序; - 在使用全局的
ensure系列函数,给出_fail作为判分结果时,身份则默认为数据配置。
此时,若身份为数据配置,则会自动将 _wa、_pe 等判分结果转化为 _fail,说明错误并非来自选手程序本身。因此,在实现 Checker 时,可以将 ouf 和 ans 放在同一判断逻辑内,并使用各自的成员函数进行退出或断言,此时 Testlib 会自动根据二者的身份给出正确的判分结果。
8.5 appes 模式
在退出函数中,提示信息将会同时写入到结果文件和标准错误流 stderr 中。而在 Checker 和 Interactor 中,可以使用 -appes 参数启用 appes 模式,从而让 Testlib 向结果文件写入更容易识别的结构化 XML 格式文本。
格式化 XML 文本的大致结构如下所示:
<?xml version="1.0" encoding="windows-1251"?><result outcome = "accepted">Correct!</result>
<result></result> 标签部分包含了详细的结果信息,其中:
-
outcome属性值代表了测试结果,可能的值如下: ::::info[outcome所有可能的属性值]判分类型 outcome属性值_okaccepted_wawrong-answer_pepresentation-error_failfail_dirtfail_pointspoints或relative-scoring_unexpected_eofunexpected-eof_partiallypartially-correct::::
- 在以
_pc(x)作为判分结果时,pctype的属性值为x; - 在以
_points作为判分结果并给出数值作为部分分值时,points的属性值为部分分值; - 标签内容为提示信息。
可以通过 setAppesModeEncoding(string appesModeEncoding) 设置 XML 格式文本中的 encoding 属性值,其默认为 windows-1251,而所有可选值为:
::::info[encoding 所有可选值]
"ascii", "utf-7", "utf-8", "utf-16", "utf-16le", "utf-16be", "utf-32", "utf-32le", "utf-32be", "iso-8859-1", "iso-8859-2", "iso-8859-3", "iso-8859-4", "iso-8859-5", "iso-8859-6", "iso-8859-7", "iso-8859-8", "iso-8859-9", "iso-8859-10", "iso-8859-11", "iso-8859-13", "iso-8859-14", "iso-8859-15", "iso-8859-16", "windows-1250", "windows-1251", "windows-1252", "windows-1253", "windows-1254", "windows-1255", "windows-1256", "windows-1257", "windows-1258", "gb2312", "gbk", "gb18030", "big5", "shift-jis", "euc-jp", "euc-kr", "euc-cn", "euc-tw", "koi8-r", "koi8-u", "tis-620", "ibm437", "ibm850", "ibm852", "ibm855", "ibm857", "ibm860", "ibm861", "ibm862", "ibm863", "ibm865", "ibm866", "ibm869", "macroman", "maccentraleurope", "maciceland", "maccroatian", "macromania", "maccyrillic", "macukraine", "macgreek", "macturkish", "machebrew", "macarabic", "macthai", "hz-gb-2312", "iso-2022-jp", "iso-2022-kr", "iso-2022-cn", "armscii-8", "tscii", "iscii", "viscii", "geostd8", "cp949", "cp874", "cp1006", "cp775", "cp858", "cp737", "cp853", "cp856", "cp922", "cp1046", "cp1125", "cp1131", "ptcp154", "koi8-t", "koi8-ru", "mulelao-1", "cp1133", "iso-ir-166", "tcvn", "iso-ir-14", "iso-ir-87", "iso-ir-159"
::::
9 初始化函数
本章节对应 Testlib 代码中第 4581 行开始的部分。
在“基础介绍”章节提到,每个环境都有专属的初始化函数。本章节主要讨论这些初始化函数对应的运作流程,以及过程中涉及到的部分特性。
9.1 Generator
Generator 应当使用 registerGen(argc, argv, randomGeneratorVersion) 函数初始化,其中 randomGeneratorVersion 为随机函数的版本号。这个函数的行为如下:
- 校验随机版本为
0或1,并设置random_t::version; - 执行
__testlib_ensuresPreconditions()(环境前置条件检查); - 标记
TestlibFinalizeGuard::registered = true; - 将运行模式设置为
_generator; - 对
stdin进行二进制模式设置; - 使用
rnd.setSeed(argc, argv)通过命令行参数初始化随机种子; - 在支持的编译器下调用
prepareOpts(argc, argv)解析命令行参数(供opt(...)使用)。
旧版 Testlib 使用 registerGen(argc, argv) 进行初始化,其内部默认使用旧版随机,出于兼容性考虑保留至今。
9.2 Validator
Validator 通常通过 registerValidation() 或 registerValidation(argc, argv) 初始化。其中,registerValidation() 的行为如下:
- 执行
__testlib_ensuresPreconditions(); - 标记
TestlibFinalizeGuard::registered = true; - 将运行模式设置为
_validator; - 对
stdin、stdout、stderr进行二进制模式设置; - 初始化
inf为标准输入并启用严格模式(inf.strict = true)。
registerValidation(argc, argv) 的行为如下:
- 先调用
registerValidation()完成基础初始化; - 解析
--testset和--group并写入 Validator 配置; - 调用
validator.initialize(); - 解析并设置
--testOverviewLogFileName、--testMarkupFileName、--testCase、--testCaseFileName等选项; - 对
--testCase做范围校验(应当在[1, __TESTLIB_MAX_TEST_CASE)范围内)。
若你希望在 Validator 中读取 testset 或 group 或测试点相关配置,应优先使用带 argc, argv 的初始化入口。
9.3 Checker
Checker 应当使用 registerTestlibCmd(argc, argv) 初始化。这个函数的行为如下:
- 执行
__testlib_ensuresPreconditions(); - 解析
--testset与--group(写入内部状态); - 标记
TestlibFinalizeGuard::registered = true; - 将运行模式设置为
_checker并对stdin设为二进制模式; - 解析参数并处理
--help; - 校验参数数量(
<input> <output> <answer>及可选report-file、-appes); - 设置
resultName与appesMode(非 EJUDGE 环境下解析-appes); - 初始化
inf、ouf、ans,并对ouf进行 BOM 跳过。
兼容入口 registerTestlib(argc, ...) 的行为如下:
- 校验参数数量(
3..5); - 将可变参数组装为
argv,随后调用registerTestlibCmd(argc + 1, argv)。
registerTestlib 主要用于兼容旧代码;在新代码中更推荐直接使用 registerTestlibCmd(argc, argv)。
9.4 Interactor
Interactor 应当使用 registerInteraction(argc, argv) 初始化。这个函数的行为如下:
- 执行
__testlib_ensuresPreconditions(); - 解析
--testset与--group(写入内部状态); - 标记
TestlibFinalizeGuard::registered = true; - 将运行模式设置为
_interactor并对stdin设为二进制模式; - 解析
--help与参数数量(<input> <output> [<answer> [<report> [-appes]]]); - 设置
resultName与appesMode(非 EJUDGE 环境下解析-appes); - 初始化
inf为输入文件; - 打开
tout写入结果文件; - 将
ouf绑定到标准输入流; - 若提供
answer-file则初始化ans,否则标记为未打开。
Interactor 的参数形式与 Checker 相似但并不完全相同,尤其是 answer-file 与 report-file 的可选关系。
9.5 前置条件
所有初始化函数都会调用 __testlib_ensuresPreconditions() 检查前置条件,其检查内容主要包括:
- 基本类型大小假设(
int、long long、double); INT_MAX常量值;- 确认 Testlib 自行实现的 NaN 能够正常工作。
若不满足前置条件,Testlib 会以 _fail 退出并给出原因。
9.6 Testset 和 Group
围绕 --testset 与 --group,初始化流程的处理逻辑如下:
- 在
registerInteraction与registerTestlibCmd中解析并写入内部状态; - 在
registerValidation(argc, argv)中解析并写入 Validator 对象; - 可通过
getTestset()与getGroup()获取全局 testset/group 值。
Checker 与 Validator 还提供了用于设置与读取的成员函数:
checker.setTestset(...)、checker.setGroup(...)与validator.setTestset(...)、validator.setGroup(...)会覆盖当前的 testset/group(通常由初始化函数内部调用);checker.testset()、checker.group()与validator.testset()、validator.group()会读取当前值,但要求使用带argc, argv的初始化入口,否则会报错。
需要注意,若需要在 Checker 或 Validator 中读取 testset 和 group,请确保使用带 argc, argv 的初始化入口。
10 命令行参数解析
本章节对应 Testlib 代码中第 5426 行开始的部分。
对于 Generator,可以通过命令行参数传递数据参数。Testlib 目前支持通过如下方式编写命令行参数:
-keyName=value或--keyName=value,代表将keyName键的值设置为value;-keyName value或--keyName value,含义与前者相同;-kNumval或--kNumval,代表对于仅包含单个字符的键k,将其值设置为无符号整数Numval;-boolProperty或--boolProperty,代表将boolProperty键的值设置为true。
在使用时,可以使用 opt<T>(key/index) 和 opt<T>(key/index, default_value) 获取键值,其中前者在键值不存在时报错:Opts: unknown key 'xxx',而后者会返回默认值。如果想要判断某个键是否存在,可以使用 has_opt(key) 函数。
Testlib 会根据类型 T 的种类动态解析值,并返回对应的结果。目前可用的类型为:
- 整型,如
int、long long等,此时将会尝试把值转换为整数,在发生整型溢出时抛出异常。你可以使用科学计数法(如2e5)给出整数值,处理方案在技术分析中给出; - 浮点型,如
float、double等,此时将会尝试把值转换为long double类型,并将结果强制转换为所需类型(此时可能产生精度丢失或溢出等)。你依然可以使用科学计数法给出浮点值; - 布尔类型,即
bool。如果键没有对应的值,则视为假;否则,只有四个可用的值:true、1、false和0,其中前两个值视为真,后两个视为假。若值不属于上述四个可用值,则会报错; - 字符串类型,即
string。实际上,opt(key/index)行使了和opt<string>(key/index)一样的功能。
需要注意的是,在直接调用 has_opt(key),或者调用 opt(key, default_value) 或 opt<bool>(key) 而间接调用 has_opt 的时候,Testlib 会强制你使用所有定义过的键值对,若没有完全使用则会报错。这一限制可以通过调用 suppressEnsureNoUnusedOpts() 关闭。
::::info[Testlib 如何实现命令行解析?]
命令行解析的主函数为 prepareOpts,其实现如下:
/**
* Parse command line arguments into opts.
* The results are stored into __testlib_argv and __testlib_opts.
*/
void prepareOpts(int argc, char *argv[]) {
if (argc <= 0)
__testlib_fail("Opts: expected argc>=0 but found " + toString(argc));
size_t n = static_cast<size_t>(argc); // NOLINT(hicpp-use-auto,modernize-use-auto)
__testlib_opts = std::map<std::string, TestlibOpt>();
for (size_t index = 1; index < n; index += parseOpt(n, argv, index, __testlib_opts));
__testlib_argv = std::vector<std::string>(n);
for (size_t index = 0; index < n; index++)
__testlib_argv[index] = argv[index];
}
上述代码主要完成两部分工作:
- 通过
parseOpt分析除了可执行文件名之外其余的命令行参数部分,将解析结果以键值对的形式写入__testlib_opts中; - 将所有命令行参数写入一个
vector<string>中,从而支持opt(int index)和opt(int index, const T &default_value)的语法。
parseOpt 函数实现了上面提供的四种解析格式。在解析参数时,采用贪婪匹配原则:如果在当前命令行参数中发现等号,则视为上述第一种方式;否则,如果下一个命令行参数不满足解析格式,则视为上述第二种方式;否则,根据第二个字符是否为数字字符区分后两种方式。
在实际存储时,参数值 TestlibOpt 包含了两个值:string value 储存了值的字符串表示,而 bool used 表示这个值是否被使用过。每次通过键获取值时,都会将 used 标记为 true,在最后可以通过检查所有值的使用情况完成前面提到的限制——“强制使用所有定义过的键值对”。
最后简单说明一下整型和浮点型的解析过程。在正式解析之前,值将会通过 parseExponentialOptValue 函数进行处理,这个函数会识别科学计数法并转换为定点表示。随后,将会根据情况分为整数、浮点数和布尔值分别处理。
::::
11 Scorer 相关
本章节对应 Testlib 代码中第 5906 行开始的部分。
考虑到 Scorer 依然是一个实验性的功能,本章节仅简单介绍 Scorer 涉及到的接口等。
你需要使用 registerScorer 函数初始化一个 Scorer 环境,其实现如下:
void registerScorer(int argc, char *argv[], std::function<double(std::vector<TestResult>)> scorer) {
/* Suppress unused. */
(void)(argc), (void)(argv);
__testlib_ensuresPreconditions();
testlibMode = _scorer;
__testlib_set_binary(stdin);
inf.init(stdin, _input);
inf.strict = false;
__testlib_scorer = scorer;
}
可以看到,这个实现不会使用命令行参数,将标准输入流与 inf 绑定,同时存储你传入的 scorer 函数。这个函数接受一个 vector<TestResult> 作为参数,并返回一个 double,作为判分结果。TestResult 类包含了某一次测试对应的全部信息,其定义如下所示:
::::info[TestResult 类的定义]
enum TestResultVerdict {
SKIPPED,
OK,
WRONG_ANSWER,
RUNTIME_ERROR,
TIME_LIMIT_EXCEEDED,
IDLENESS_LIMIT_EXCEEDED,
MEMORY_LIMIT_EXCEEDED,
COMPILATION_ERROR,
CRASHED,
FAILED
};
struct TestResult {
int testIndex;
std::string testset;
std::string group;
TestResultVerdict verdict;
double points;
long long timeConsumed;
long long memoryConsumed;
std::string input;
std::string output;
std::string answer;
int exitCode;
std::string checkerComment;
};
::::
除此之外,Testlib 为 TestResult 类实现了序列化和反序列化的接口。在程序结束时,Testlib 将会通过 inf 读取所有行,在尝试反序列化之后拼接得到所有测试结果信息。这些测试结果将会作为参数传入你编写的 scorer 函数中,而返回值则会输出到标准输出流。
::::info[上述流程对应的代码实现]
struct TestlibScorerGuard {
~TestlibScorerGuard() {
if (testlibMode == _scorer) {
std::vector<TestResult> testResults;
while (!inf.eof()) {
std::string line = inf.readLine();
if (!line.empty())
testResults.push_back(deserializeTestResult(line));
}
inf.readEof();
printf("%.3f\n", __testlib_scorer(testResults));
}
}
} __testlib_scorer_guard;
::::
为了让测试平台支持 Scorer,需要在内核中为 Scorer 提供正确序列化的测试结果,作为 Scorer 的输入数据。除此之外,Scorer 不应当主动调用 quit 系列函数退出,因此建议除了初始化函数之外,应当只实现算分相关的辅助逻辑。
12 全局函数
本章节整理所有可以被直接调用的全局函数(包含前文已介绍的函数)。
12.1 初始化与环境
| 函数 | 功能 |
|---|---|
registerGen(argc, argv, randomGeneratorVersion) |
初始化 Generator(含随机版本与命令行参数解析)。 |
registerGen(argc, argv) |
上述函数的兼容入口,默认使用旧版随机。 |
registerValidation() |
初始化 Validator(标准输入、严格模式等)。 |
registerValidation(argc, argv) |
同上,但额外解析 --testset/--group 与测试点选项。 |
registerTestlibCmd(argc, argv) |
初始化 Checker 并解析命令行参数。 |
registerTestlib(argc, ...) |
上述函数的兼容入口,内部转发至 registerTestlibCmd。 |
registerInteraction(argc, argv) |
初始化 Interactor 并解析命令行参数。 |
registerScorer(argc, argv, scorer) |
初始化 Scorer 并注册评分回调。 |
setAppesModeEncoding(encoding) |
设置 appes XML 输出的 encoding。 |
setName(format, ...) |
设置 Checker 名称(用于帮助信息)。 |
setTestCase(testCase) |
设置当前测试点编号(Validator 专用)。 |
unsetTestCase() |
清除测试点编号标记。 |
getTestset() |
获取全局 testset 字符串。 |
getGroup() |
获取全局 group 字符串。 |
disableFinalizeGuard() |
关闭结束时的完整性检查(谨慎使用)。 |
12.2 退出与断言
| 函数 | 功能 |
|---|---|
quit(result, message) |
以给定判分结果与提示信息退出。 |
quitf(result, format, ...) |
以格式化信息退出。 |
quitp(points, message) |
以分数结果退出。 |
quitpi(pointsInfo, message) |
使用文本化分数信息退出。 |
quitif(condition, result, format, ...) |
条件为真时调用 quitf。 |
expectedButFound(result, expected, found, ...) |
给出“期望为……而实际为……”类提示信息并退出。 |
ensure(cond) |
断言式检查,失败时以 _fail 退出。 |
ensuref(cond, format, ...) |
同上,不过可以自定义错误信息。 |
12.3 Validator 特性
| 函数 | 功能 |
|---|---|
addFeature(feature) |
在 Validator 中添加特性标签。 |
feature(feature) |
在 Validator 中确认某一特性标签得到满足。 |
12.4 命令行参数解析(opts)
| 函数 | 功能 |
|---|---|
has_opt(key) |
判断键是否存在。 |
opt<T>(index) |
从参数索引读取并解析为类型 T。 |
opt<T>(index, defaultValue) |
同上,但索引越界时返回默认值。 |
opt<T>(key) |
从键读取并解析为类型 T。 |
opt<T>(key, defaultValue) |
同上,但键不存在时返回默认值。 |
opt(index) |
以字符串形式读取参数索引值。 |
opt(index, defaultValue) |
同上,但索引越界时返回默认字符串。 |
opt(key) |
以字符串形式读取键值。 |
opt(key, defaultValue) |
同上,但键不存在时返回默认字符串。 |
ensureNoUnusedOpts() |
校验是否存在未使用的 opt。 |
suppressEnsureNoUnusedOpts() |
关闭自动未使用校验。 |
12.5 字符串与格式化
| 函数 | 功能 |
|---|---|
format(fmt, ...) |
以 printf 风格格式化参数并返回字符串。 |
upperCase(s) |
将字符串中的小写英文字母转换为大写。 |
lowerCase(s) |
将字符串中的大写英文字母转换为小写。 |
compress(s) |
压缩字符串用于输出(保留可读前缀)。 |
englishEnding(x) |
返回英文序数后缀(st/nd/rd/th)。 |
trim(s) |
去除字符串两端的空白字符。 |
join(first, last, separator) |
将迭代器范围内元素按分隔符拼接。 |
join(first, last) |
同上,但使用空格作为分隔符。 |
join(collection, separator) |
拼接容器中的元素为字符串。 |
join(collection) |
同上,但使用空格作为分隔符。 |
split(s, separator) |
按单个分隔符拆分,保留空项。 |
split(s, separators) |
同上,但分隔符集合为多个字符。 |
tokenize(s, separator) |
按单个分隔符拆分,丢弃空项。 |
tokenize(s, separators) |
同上,但分隔符集合为多个字符。 |
12.6 浮点比较
| 函数 | 功能 |
|---|---|
doubleCompare(expected, result, maxError) |
判断浮点误差是否在允许范围内。 |
doubleDelta(expected, result) |
计算绝对或相对误差。 |
12.7 输出与打印
| 函数 | 功能 |
|---|---|
startTest(test) |
将标准输出重定向到以 test 命名的文件。 |
println(x) |
输出单个对象并换行。 |
println(a, b) |
输出两个对象并换行(或将迭代器区间按空格输出)。 |
println(a, b, c[, ...]) |
输出多个对象并换行(最多 7 个)。 |
12.8 容器与随机辅助
| 函数 | 功能 |
|---|---|
shuffle(first, last) |
随机打乱迭代器范围(使用 rnd)。 |
random_shuffle(first, last) |
被禁用的旧接口,调用会直接报错。 |
rand() / srand(seed) |
被禁用的旧接口,调用会直接报错。 |
12.9 Scorer 序列化与读写
| 函数 | 功能 |
|---|---|
serializeVerdict(verdict) |
将 TestResultVerdict 序列化为字符串。 |
deserializeTestResultVerdict(s) |
将字符串反序列化为 TestResultVerdict。 |
serializePoints(points) |
将分数字段序列化为字符串。 |
deserializePoints(s) |
反序列化分数字段。 |
escapeTestResultString(s) |
转义测试结果字段。 |
unescapeTestResultString(s) |
反转义测试结果字段。 |
serializeTestResult(tr) |
序列化 TestResult。 |
deserializeTestResult(s) |
反序列化 TestResult。 |
readTestResults(fileName) |
从文件读取并反序列化所有测试结果。 |