Testlib 从入门到精通

· · 科技·工程

前言

Testlib 是由 Codeforces 的创办者 Mike Mirzayanov 打造的一份专注于数据层面的 C++ 功能库,其中包含 Generator、Validator、Interactor 和 Checker 四个模块,基本覆盖了出题流程中数据相关的全部环节。

这个项目存放在 GitHub 上。你可以从这里查看这个项目,或者从 Release 页面下载整个库和示例文件(不过,这个仓库似乎很久都没有发布新的 Release 了)。

本文基于 Testlib 于 2025 年 12 月 18 日发布的版本,可以使用这个链接查看修改历史。另外,本文的介绍重点在于 Testlib 的使用方式上,而技术细节将会通过折叠框的形式出现。

1 基础介绍

这一章节主要讨论 Testlib 的基本属性,从而能够快速上手。

1.1 使用环境

Testlib 提供了五种使用环境:

其中,Scorer 作为一个实验性的环境,在一些主流 OJ 上并不会经常出现(或者被自定义计分脚本等功能替代),其主要出现在综合性较强、评分方式较复杂的比赛中,例如某些 IOI 赛制的赛事,以及 Codeforces 上的 Challenge 等。我们将会在独立的章节介绍这一环境。

为了使用 Testlib 编写相应的程序,你需要遵循如下规则:

接下来将会简单介绍前四种环境的使用方法。

1.1.1 Generator

Generator 仅用于创建测试数据,因此它不会重定向输入输出。在使用之前,需要使用 registerGen(argc, argv, 1) 语句初始化。随后,你可以直接和生成器交互,生成器也会直接将数据输出到标准输出流。以下包含三种可能的数据构造方式:

  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 使用命令行参数的哈希值作为随机种子,因此在命令行参数相同的前提下,生成的所有数据都使用了同一个随机种子,可能会导致意料之外的问题。 ::::

  1. 通过命令行传入参数。你可以通过 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 文件中。

::::

  1. 通过 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,随后:

::::

1.1.2 Validator

Validator 用于验证输入数据的完整性,需要使用 inf 变量(注:这个变量与 oufans 都是 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,对应的要求为:

随后进行编译,假设编译结果为 val.exe,通过 val.exe < data.in 即可进行验证。 ::::

1.1.3 Checker

Checker 用于根据数据文件和选手程序输出给出对应的判分结果。在使用之前,你需要使用 registerTestlibCmd(argc, argv) 语句初始化。随后,Checker 可以使用如下变量读取对应的文件信息:

你同样可以使用类似 inf.read???inf.read???s 的方法读取信息,并在代码中实现相应的判断逻辑。

Checker 必须通过 quit 系列函数退出,并显式给出判分结果和提示信息。最常见的 quit 系列函数为 quitf(result, format, ...),其中 result 为判分结果,而 format 与后面的不定长参数共同形成提示信息。可用的 result 类型如下:

最后,每个 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,用于判定输出中是否存在大小写不敏感的 YesNo,并验证选手输出是否和输出数据文件匹配。此时:

随后进行编译,假设编译结果为 check.exe,通过 check.exe data.in user.out data.ans 即可获得判分结果。例如:

关于各判分结果的完整语义、退出函数以及 appes 模式输出格式,详见第八章“退出函数和断言函数”。 ::::

1.1.4 Interactor

Interactor 主要用于 I/O 交互中,通过与选手程序的标准输入输出流建立连接实现交互功能。在使用之前,你需要使用 registerInteraction(argc, argv) 语句初始化。与其余三个使用环境不同,Interactor 涉及到多个输入输出流:

对于一个 I/O 交互题,以下为整体的测试流程:

::::info[示例:I/O 交互猜数的配置方案]{open} 这一部分的内容来自 Polygon 的交互题示例。考虑如下问题:

:::info[I/O 交互猜数]{open} 现有一个不超过 n1 \leq n \leq 10^6)的正整数 x,你需要通过交互猜出这个值。

首先,从标准输入中读取一个整数 n。随后,你可以进行若干次询问,每一次询问只需要输出一行一个数字 y1 \leq y \leq n)并刷新缓冲区,此时:

你需要在不超过 25 次询问中确定 x 的值,并通过 ! x 的格式输出。需要注意的是,本题的交互是适应性的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 执行了如下流程:

上述高亮行同时使用了 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 的配置方案。 ::::

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 设置单测试数据文件的位置,包含编号为特定测试点编号的测试输入

:::

参数中的文件信息仅在文件位置被指定时才会写入,而文件位置可以使用 stdoutstderr 指定为标准流。另外,上述参数也可以使用 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 也可以在最后追加 testsetgroup 参数,方式和 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 也可以在最后追加 testsetgroup 参数。

1.3 基础函数

此小节将会介绍一些常用的基础函数。

1.3.1 读入相关

前面提到,infoufans 都是 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::quitInStream::quitf 退出。这意味着 _wa 状态同样经过了上述 quit 系列函数的处理,对于非选手程序输出类型的情况会转化为 _fail。如果调用全局实现,将会直接判为 _fail,即认为错误出自 Testlib 环境。

具体的原理涉及到退出时身份,其细节详见第八章“退出函数和断言函数”。 ::::

1.4 注意事项

在使用 Testlib 时,你需要注意以下几个要求:

  1. 不要使用 srand()rand()random_shuffle(),因为它们无法保证命令行参数和数据一一对应,请使用 Testlib 提供的 rnd 对象和 shuffle() 函数。
  2. Checker 的工作流程应该尽可能短,如果一些预处理工作需要较长的时间,可以将结果放在答案文件中,Checker 只需要读入使用即可;
  3. 在 Checker 中,即使选手输出文件有不必要信息,在返回结果的时候仍然需要完全读取,否则会判为 Wrong Answer。
  4. 如果选手的答案比标准答案更优秀,应该返回 _fail 并且让选手和题目负责人联系。
  5. 尽管在后面的内容中有提及,但不推荐使用 long 类型相关的函数,因为 Testlib 无法保证 long 类型占据的比特数。
  6. 在必要的情况下,尽可能保证配置的安全性,防止选手通过各种方式找到非预期行为。

另外,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 类支持的特性包括:

::::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 类包含了如下成员变量:

在实际匹配时,使用递归的方法,确定当前的正则描述属于哪一类单元,并使用正则描述的子串构造更小的 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) 产生一个 [0, n - 1] 范围内的随机整数 n > 0
long next(n) ^ ^
long long next(n) ^ ^
unsigned int next(n) ^ 0 < n < 2^{31}
unsigned long next(n) ^ ^
unsigned long long next(n) ^ 0 < n < 2^{63}
int next(from, to) 产生一个 [\text{from}, \text{to}] 范围内的随机整数 \text{from} \leq \text{to}
unsigned int next(from, to) ^ ^
long next(long from, long to) ^ \text{to} - \text{from} + 1 < 2^{31}
unsigned long next(from, to) ^ \text{from} \leq \text{to}\text{to} - \text{from} + 1 < 2^{31}
long long next(from, to) ^ \text{to} - \text{from} + 1 < 2^{63}
unsigned long long next(from, to) ^ \text{from} \leq \text{to}\text{to} - \text{from} + 1 < 2^{63}

注:上表中参数类型和返回值类型相同。

由于未知原因,其中的一些函数没有设置 \text{from} \leq \text{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 的倍数。随后,不断在 [0, 2^{63}) 范围内随机选数,直到数字不超过 limit 时,对 n 取模的结果就是随机结果。

不难发现,上述算法在任意情况下期望不超过两次随机就能得到结果,并且只要 nextBits() 的结果足够随机,[0, n - 1] 范围内每个数字作为结果的概率是相同的。 ::::

4.2 随机浮点数

你可以使用 rnd.next() 函数产生随机的浮点数。

:::align{center} next() 的重载 功能 要求
double next() 产生一个 [0, 1) 范围内的随机浮点数
double next(n) 产生一个 [0, n) 范围内的随机浮点数 n > 0
double next(from, to) 产生一个 [\text{from}, \text{to}) 范围内的随机浮点数 \text{from} < \text{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 比特的数字,并将其除以 2^{53} 作为随机结果。在 C++ 中,double 的尾数部分包含 52 位,可以表示 53 位的精度,故其恰好能精确储存通过上述方式计算得到的结果。

这里需要注意,为了避免随机序列的生成方式对结果的某些比特产生可被利用的统计学特性,这里在分组生成时采用了均分的方案,而不是通过 nextBits(63) 生成。

剩余的随机函数实现仅在这个函数的基础上进行放缩和偏置,这里不再展开。 ::::

4.3 容器内、迭代器间随机

在 Testlib 中,可以使用 rnd.any(container)rnd.any(begin, end) 获取一个非空的可枚举容器迭代器区间(包含 begin 但不包含 end,即左闭右开)内部的任意元素。它们的运作流程如下:

可以发现,上述流程中 rnd.any(container) 调用了容器的 size() 函数和常值迭代器的 advance 函数,而 rnd.any(begin, end) 调用了迭代器的 distanceadvance 函数。因此,在使用上述函数之前,需要事先确认调用的时间复杂度。例如,随机选取 set 内部元素的时间复杂度为 \mathcal{O}(\text{size}),而随机选取 vector 或数组内部元素的时间复杂度总是 \mathcal{O}(1)

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 的绝对值不小于 random_t 内部设置的限制(默认为 25)时,会使用特殊的算法得到随机结果,以保证在有限的调用次数下得到概率上足够相近的随机分布。因此,你无需考虑 type 的绝对值过大导致内部随机次数过多的情况出现。

::::info[Testlib 如何处理 type 的绝对值较大的情况?] 不妨记 t 等于第二个参数 type,且此时 t \geq 25。另有随机变量

X = \max_{i=0}^{t} X_i

其中 X_i \sim U(0, 1),也即 X_i 服从区间从 01 上的连续均匀分布。此时对任意 0 \leq x \leq 1 都有

P(X \leq x) = \prod_{i=0}^{t} P(X_i \leq x) = x^{t+1}

因此,变量 X 的分布函数为 F(x) = P(X \leq x) = x^{t+1},现在使用另一个随机变量 Y \sim U(0, 1) 得到这一分布函数。令 Z = T^{\frac{1}{t+1}},注意到

P(X \leq x) = x^{t+1} = P(Y \leq x^{t+1}) = P(Z \leq x)

因此 ZX 服从同一分布,实际计算中随机取 0 \leq y \leq 1 并计算 z = y^{\frac{1}{t+1}} 即可。

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() 也有三种重载:

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 类对数据文件和选手程序输出进行读取。它们分别为 infoufans。需要明确的是,它们除了绑定不同的流之外,还具有不同的模式。具体而言,Testlib 具有三种读入类模式:

enum TMode {
  _input, _output, _answer
};

在实际情况中,infoufans 的模式分别为 _input_output_answer。尽管模式并不会直接影响后续读取内容的流程,但它们标记了 InStream 类发生读取错误时代表的情况。例如,一个 _output 模式类在读取错误时,会认为选手程序输出错误,判分结果为 _wa;否则,一个 _input_answer 模式类在读取错误时,会认为数据出现问题,判分结果为 _fail

另外,Validator 中 inf 变量会自动进入严格模式,此时需要满足如下的额外需求:

一般来说,严格模式的限制会作用于所有读取函数,而除去空白字符处理之外,一些特殊的行为会单独列出介绍。

下面将会介绍 InStream 可以调用的函数,不过出于后续篇幅需要,这里提前给出读入函数可能支持的四种特性:

上面的参数追加需要按照顺序进行,无法打乱。你可以通过查看读入函数的重载进一步确认可用的参数组合。

需要注意的是,变量名同时会用于 Validator 生成的日志总览文件中。这个文件会在范围限定下显示某一个变量是否取到下界或上界。你可以使用 ~n 变量名取消下界检查,以此类推。详细信息详见第六章“Validator 规范”。

5.1 初始化

除了已有的 infoufans 之外,你应当只使用如下构造函数创建新的 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 类变量而言,有三个可以修改的参数:

如果需要对 infoufans 的参数进行修改,请务必通过类似 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() 的形式会受到如下宏的影响:

此时,在宏满足下面两种状态之一时,则仅检查流的开头是否为 #13#10,否则仅检查流的开头是否为 #10

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) ^ ^

:::

在上述函数中,严格浮点数需要满足如下要求:

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) ^ ^

:::

以下是在读取一行字符时需要注意的事情:

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;
};

从上面的类可以派生出如下三个类:

为了实现多态,在 InStream 内部包含一个 InputStreamReader* 指针,所有读取方法都会通过该指针指向的成员函数间接读取字符串或文件流。在 Testlib 初始化时,所有与标准流(如 stdinstdoutstderr)绑定的 InStream 类内部使用 FileInputStreamReader 管理,而所有与文件绑定的 InStream 类内部使用 BufferedFileInputStreamReader 管理。在运行过程中通过构造函数创建的 InStream 类内部使用 StringInputStreamReader 管理。 ::::

6 Validator 规范

本章节对应 Testlib 代码中第 2389 行开始的部分。

从“基础介绍”章节中提到,Validator 可以向日志总览文件、标记文件和单测试数据文件写入信息。实际上,Validator 不仅启用了严格读入模式,还实时追踪了变量信息和测试数据信息。本章节主要通过介绍上述三个文件的格式以及对应的操纵逻辑展开。

6.1 日志总览文件

日志总览文件按顺序包含如下信息:

在使用 InStream 读入时追加的变量名和范围限定会被 Validator 追踪。Validator 最多跟踪 255 个变量的信息,而对于变量名相同的数字型变量,Validator 会追踪其每次读入时设置的上下界是否固定,并记录其是否在某次读入时触及上界或下界。具体地,认为变量在读入时触及上界或下界,当且仅当读取结果和上界或下界的值相同(对于浮点数而言则是相差不超过 10^{-12})。

如果想要让 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_treechaindaisy,而只有前两个特性得到满足。形式上来讲,这可能说明读入的图是一个链,但不是菊花图。

日志总览文件的第三部分,即“变量的固定上下界”部分的格式如下:

constant-bounds "a": 1 100
constant-bounds "b": 1 ?
constant-bounds "c": ? ?
constant-bounds "d": ? ?

上述内容表示数字型变量 a 在读入时设置的下界固定为 1、上界固定为 100b 的下界固定为 1,而上界不是固定的;cd 的上界和下界都不是固定的。

日志总览文件的最后一部分,即“所有变量的名称”部分的格式如下:

variable "a"
variable "b"
variable "c"
variable "d"
variable "mode"
variable "query"

上述内容表示在读入时曾声明过的变量名为 abcdmodequery。需要注意的是,这里的变量名不仅包括数字型变量,还包括字符串型变量。

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 退出时,会检查如下限制是否被满足:

你可以通过 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 部分正确类型基值

在实际调用时,需要注意如下几点:

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 类似,但仅在 conditionfalse 时退出
void quits(TResult result, string msg) quit 类似
void quitp(T points, const std::string &message = "")(全局) 选手程序身份给出 _points 作为判分结果,points 作为部分分值,message 作为提示文本,其中 T 可以为 floatdoublelong doubleint
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 类似,但仅在 conditionfalse 时退出
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)(全局) condfalse给出 _fail 作为判分结果
void ensuref(bool cond, const char *format, ...)(全局) ensure 类似,但可以自定义提示信息
void InStream::ensure(bool cond) condfalse 时调用 quit 成员函数并给出 _wa 作为判分结果
void InStream::ensuref(bool cond, const char *format, ...) InStream::ensure 类似,但可以自定义提示信息

:::

需要注意的是,InStream 内部实现的 ensure 成员函数默认给出 _wa 作为判分结果,而这一判分结果可能会根据 InStream 的身份转化为 _fail

8.4 退出时身份

在“读入相关类”章节中提到,全局实现中的退出函数内部会直接调用 InStream 的退出函数。因此,在讨论退出函数时,需要引入“身份”这一概念,即谁导致了本次退出。具体而言:

此时,若身份为数据配置,则会自动将 _wa_pe 等判分结果转化为 _fail,说明错误并非来自选手程序本身。因此,在实现 Checker 时,可以将 oufans 放在同一判断逻辑内,并使用各自的成员函数进行退出或断言,此时 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> 标签部分包含了详细的结果信息,其中:

可以通过 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 为随机函数的版本号。这个函数的行为如下:

旧版 Testlib 使用 registerGen(argc, argv) 进行初始化,其内部默认使用旧版随机,出于兼容性考虑保留至今。

9.2 Validator

Validator 通常通过 registerValidation()registerValidation(argc, argv) 初始化。其中,registerValidation() 的行为如下:

registerValidation(argc, argv) 的行为如下:

若你希望在 Validator 中读取 testset 或 group 或测试点相关配置,应优先使用带 argc, argv 的初始化入口。

9.3 Checker

Checker 应当使用 registerTestlibCmd(argc, argv) 初始化。这个函数的行为如下:

兼容入口 registerTestlib(argc, ...) 的行为如下:

registerTestlib 主要用于兼容旧代码;在新代码中更推荐直接使用 registerTestlibCmd(argc, argv)

9.4 Interactor

Interactor 应当使用 registerInteraction(argc, argv) 初始化。这个函数的行为如下:

Interactor 的参数形式与 Checker 相似但并不完全相同,尤其是 answer-filereport-file 的可选关系。

9.5 前置条件

所有初始化函数都会调用 __testlib_ensuresPreconditions() 检查前置条件,其检查内容主要包括:

若不满足前置条件,Testlib 会以 _fail 退出并给出原因。

9.6 Testset 和 Group

围绕 --testset--group,初始化流程的处理逻辑如下:

Checker 与 Validator 还提供了用于设置与读取的成员函数:

需要注意,若需要在 Checker 或 Validator 中读取 testset 和 group,请确保使用带 argc, argv 的初始化入口。

10 命令行参数解析

本章节对应 Testlib 代码中第 5426 行开始的部分。

对于 Generator,可以通过命令行参数传递数据参数。Testlib 目前支持通过如下方式编写命令行参数:

在使用时,可以使用 opt<T>(key/index)opt<T>(key/index, default_value) 获取键值,其中前者在键值不存在时报错:Opts: unknown key 'xxx',而后者会返回默认值。如果想要判断某个键是否存在,可以使用 has_opt(key) 函数。

Testlib 会根据类型 T 的种类动态解析值,并返回对应的结果。目前可用的类型为:

需要注意的是,在直接调用 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 函数实现了上面提供的四种解析格式。在解析参数时,采用贪婪匹配原则:如果在当前命令行参数中发现等号,则视为上述第一种方式;否则,如果下一个命令行参数不满足解析格式,则视为上述第二种方式;否则,根据第二个字符是否为数字字符区分后两种方式。

在实际存储时,参数值 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) 从文件读取并反序列化所有测试结果。