项目 | 内容 |
---|---|
这个作业属于哪个课程 | 2023年北航敏捷软件工程社区 |
这个作业的要求在哪里 | 结对项目-最长英语单词链 |
我在这个课程的目标是 | 熟悉并在实践中体会软件开发流程,学习软件构建思维,提升编码能力 |
这个作业在哪个具体方面帮助我实现目标 | 在实践中体验了软件开发的团队协作过程,一定程度上提高了跨生态开发的编码能力与信息整理能力 |
1. 项目信息
- 教学班级:周四班
- 项目地址:BUAA_SE_MaximumWordsChain
2. PSP 表格(预估版)
3. 接口设计思想
首先给出我们的接口设计如下:
extern "C" __declspec(dllexport) int gen_chains_all(const char *words);
extern "C" __declspec(dllexport) int
gen_chain_word(const char *words, char head, char tail, char prohibit,
bool enable_loop); // most words
extern "C" __declspec(dllexport) int
gen_chain_char(const char *words, char head, char tail, char prohibit,
bool enable_loop); // most letter
extern "C" __declspec(dllexport) double get_execution_time();
extern "C" __declspec(dllexport) char *getResult();
Information Hiding
Information Hiding,也称封装。其核心思想是将组件内部的细节和实现隐藏在组件外部的接口之后,只向外暴露必要的接口,隐藏不必要的细节。这样可以降低组件之间的耦合度,提高代码的可维护性和可重用性。
我们向外暴露的接口都隐藏了计算模块内部的信息,只留有必要的外部参数,实现了信息隐藏的功能,成功保护了计算模块组件内部的实现。
Interface Design
我们在设计组件时,明确定义了其对外部的接口和使用规范,从而使得组件的使用和实现可以相互独立:
- 我们在设计核心代码时使用了 C++ 面向对象的原则,在计算模块间封装了异常报告、结果输出、计算核心、文本预处理四个类,每个类只暴露必要的公共接口给其他类,使得组件的设计更加灵活和具有可复用性。
- 我们向 GUI 暴露出的接口均满足单一职责原则,并且不存在功能糅合的问题。
Loose Coupling
上一节已经介绍过,我们通过分离计算程序的功能,使用接口来定义不同组件之间的通信协议,降低了组件之间的耦合度。这种设计思想提高了代码的灵活性和可维护性,同时也方便组件的单元测试和模块化开发。
4. 计算模块接口的设计与实现过程
计算模块接口设计
所有的计算都在同一个模块engine
中实现,提供int engine(int *options, char *res[])
函数用于求解。
针对与前端交互,暴露了如下几个接口:
- 返回所有单词链:
int gen_chains_all(const char* words);
- 返回单词数量最多的单词链:
int gen_chain_word(const char*
- 返回字母数量最多的单词链:
int gen_chain_char(const char* words, char head, char tail, char prohibit, bool enable_loop);
- 返回上一次操作所花费的时间:
double get_execution_time();
- 返回上一次操作的结果:
char *getResult();
其中,words
为输入的所有单词;head、tail、prohibit
分别代表指定的首字母、尾字母和禁止出现的首字母,若参数值为0则表示没有限制;enable_loop
代表是否允许形成单词环。各个接口通过调用engine
进行计算。
实现过程
抽象
将单词看做边,首尾看做节点,相连看做边,按一定规则构建图,计算最长路,得到最长的英语单词链。
一开始,我们采用了单词为点,相连为边的策略,虽然在解决单词链长度 >= 2的问题上颇有成效,但是对于自环的处理迟迟无法走上正道。于是,我们对建图过程进行了重构:
- 图用
vector<pair<int,int>> graph[60][60]
表示 - 点:
a-z
的26个字母 - 边:存在一个单词,s.t.首字母 = 起点,尾字母 = 终点
- 边权:-n/-w边权为1,-c边权为单词长度
- 举例:如果存在
buaaSE
这样的词,则graph[b][e] = {边权, 边编号}
实现
-
两个需要解决的问题:自环、单词链中单词数量需要大于 2。
-
判环:没有
-r
参数时需要先判断图中无环。这里采用拓扑排序进行环的判断。但是,当存在首位字母相同的单词时,此图中出现自环。对自环数量进行讨论:- 如果图中各个点上的自环数量 <= 1,则自环不会影响单词环的形成,这个时候删去自环,直接运行拓扑排序即可。举例:
a ab
。 - 如果图中存在至少一个点,使得这个点上的自环数量 >= 2,那么这两个自环自己就会形成单词环,直接返回结果。举例:
aa aba
。
- 如果图中各个点上的自环数量 <= 1,则自环不会影响单词环的形成,这个时候删去自环,直接运行拓扑排序即可。举例:
-
-n
:采用 dfs 暴力搜索出所有可能的单词数量 >=2 的单词链 -
-w
、-c
- 目标:采取动态规划求解,用
d
i
s
t
i
dist_i
disti 表示以字母
i
为首字母得到的最长链长度。 - 在之前的建图过程中,为了进行拓扑排序,我们把自环拿掉了。现在为了求解还需要把它们重新连上。为了更方便处理自环,不在使用边权计算答案的情况下再引入点权,我们需要修改当前的图。
- 由于没有环,所以每个点上的自环最多存在一条。于是考虑新加入
a'-z'
26个节点,即a-z
对应下标0-25,a'-z'
对应下标 26-51 ,意为把a
点拆成a->a'
,原a
的入边不变,出边改为从a'
指出即可。对于没有自环的节点,不变。 - 重新进行拓扑排序,根据倒序进行答案的更新:有
j
←
i
j\gets i
j←i,则
d
i
s
t
i
=
m
a
x
(
d
i
s
t
i
,
d
i
s
t
j
+
w
i
j
)
dist_i = max(dist_i, \;\,dist_j + w_{ij})
disti=max(disti,distj+wij) ,同时记录
i
的“前驱”节点为j
。 - 此时我们拿到了所有的字母为首的最长链长度,要开始研究如何获得答案。
- 这里我遇到了一个棘手的问题,即如何保证最长链的单词数量 >= 2,最后是参考了往届学长的做法,对前两个字母进行了暴力搜索,保证了单词数 >= 2的情况下再进行最大答案的统计。
- 目标:采取动态规划求解,用
d
i
s
t
i
dist_i
disti 表示以字母
-
-r
- 鉴于上面提到的重新建图方法,本想采取改变
graph
的存储方式为priority_queue
,按边长从大到小排序,每用掉一条边就pop
一次,回溯时再加回来的暴搜方法求解,但是碍于这样又无法解决链长需要大于2的问题,弃之。 - 虽然是n!的复杂度,但惧于近似算法的正确性无法保证(以及想不到),最后依旧采用了暴力搜索所有结果的方法。
- 鉴于上面提到的重新建图方法,本想采取改变
-
-h
- 简化计算过程,使得无论是暴力搜索还是搜索起始的字母都从给定字母出发即可。
-
-t
- 对于暴搜,判断答案的末尾字母;
- 对于动态规划,初始化的时候让别的点不可达即可,如除了给定的字母外全部初始化为
-0x3f3f3f3f
。
-
-j
- 在建图的时候就不把以给定字母为首字母的单词加进去。
对于建图过程,可以看如下图示(对于-c
,且输入单词为aa acb bcda
的情况):
5. 所在开发环境下编译器编译通过无警告证明
这里虽然看起来剩下五个 warning ,但是这五个函数没有被用到的原因是他们都是和GUI交互的接口。
6. 项目 UML 图
7. 计算模块接口部分性能改进
由于一开始不合适的建图导致程序卡在了自环的问题上,为了测试正确性,几乎全部使用的暴力搜索(-w
和-c
也是),复杂度为
O
(
n
!
)
O(n!)
O(n!) 。后为改进性能,用约三个小时重新建图并引入动态规划,成功把只有-w
和-c
的复杂度降至拓扑排序的
O
(
n
+
e
)
O(n + e)
O(n+e) 和dp的
O
(
n
e
)
O(ne)
O(ne) ,其中n
为52,e
为单词数量;并且,在只有-w和-c的情况下,建图时只保留了边权最长的边(自环同理,只会有一条边),其余边直接忽略,节省dp的时间;又由最终单词链长度不可能超过 52 (否则一定有环),而且经过删边的图最多只剩下52条边,所以这个过程其实完全是常数时间。
-n
在我们看来,只有全部暴力搜索一遍的方法,无需改进,它同样满足链长不会超过52的性质。
-r
有很大改进空间,如(以下皆为思想实验)使用priory_queue
而非vector
去存储两点之间的边,依旧使用递归回溯的方法进行搜索,每次使用边长最长的边,删去,递归,回溯时再把这条边加回来。但之所以没有继续改进的原因是改完后发现没有办法保证找到的单词链长度大于2,于是尝试再保存一条次长路(最长和次长都存在的情况下二者单词数量不可能同时为1,否则二者可以一起构成更长的环,必有一条满足条件),但在具体实现未能想到如何处理,所以最复杂的-r
依旧采用了暴力搜索的策略。
在这样一组随机数据下的-w input.txt -r
的性能体现:
lo bmktw lnupc cfalbj onl dm rvr tuuf srai pk usdft bjcpw be zxi r cgw hejy ls oxed nf lotsp p pjbrrx yevzx rgxr asz km fenaw r li k xqs nbpvy hzaii x sbt x leqv dr vlylm il qmwaw xdbbwx cif dixuk gieu mf etqhqb teh bi jpooxn hgqrm obuk oa gdvbh sio tde arrj kamxr rbxh
- Method List:
- 调用树:
- 火焰图:
可以发现,复杂度集中在 dfsMaxRing
也就是暴力搜索所有含环结果的函数中,伴随着大量遍历数组,和 vector 相关等的操作。
8. 契约思想及其实践
Design by Contract
契约式设计(DbC)是一种软件开发方法,通过阅读作业要求中给出的维基链接,笔者将其优缺点总结如下:
- 优点
- 提高软件质量:DbC 可以通过指定清晰明确的要求和对软件组件的(更细致的)期望来帮助提高软件质量,进而导致开发过程中产生更少的错误,令代码更容易调试和维护。
- 改进测试:DbC 可以通过提供清晰的软件行为规范来促进测试,这使得编写测试用例和检查软件的行为是否正确的过程变得更加容易。
- 更容易维护:DbC 通过提供清晰的软件行为规范,令诊断和修复错误以及随着需求的变化修改软件变得更加容易。
- 精确的接口令软件工程中的“人”可以更好沟通:DbC 实际上将接口的精确性转换成了一种便于人们讨论软件需求和行为的共同语言,这样一来可以改善开发者、客户和其他利益相关者之间的沟通。
- 缺点
- 增加复杂性:DbC 要求创建和管理更为正式的契约,因此会增加软件开发的复杂性。这可能使开发更加耗时,并可能需要对开发人员进行额外的培训。
- 这可能还会带来潜在的额外开销。
- 应用范围有限:DbC 在工业界没有被广泛采用,因此找到具有该方法经验的开发者可能比较困难。
- 似乎不能算是缺点,而应该是缺点带来的影响。
- 指定契约的困难:DbC 要求开发者以精确和不含糊的方式指定接口契约 —— 这可能非常困难,特别是对于复杂的系统。
在笔者的结对编程任务中,由于接口的形式和功能都非常简单,因此我们并没有使用契约式编程 —— 正如“缺点”中第一条所分析的那样,为这么简单的需求指定精确的契约可能会带来不必要的时间开销。
当然,这也给我们带来了一些小小的困扰,就是开发的中途产生过接口的多次修改。这个问题显然可以通过合理的契约避免。
Code Contract
Code Contract 是微软为 .NET 开发提供的一个契约式编程插件。通过阅读作业要求中给出的网站链接,笔者将其优缺点总结如下:(优点部分几乎就是翻译了网站上那段介绍,价值不太高)
- 优点
-
改进测试:契约在运行时检查中充当 oracles 的角色,为每次测试运行提供通过 / 失败的指示,提高了软件的可测试性。
-
静态契约验证。静态检查器可以在不执行程序的情况下识别违反契约的行为,这有助于检测隐含的契约,如空指针解引用和数组越界等问题。
- (看到静态分析一阵激动)
-
更好的文档:文档生成器可以用契约信息增强现有的 XML doc 文件。
-
语言无关性:代码合约提供了一种语言无关的方式来表达.NET程序中的编码假设,为所有 .NET 编程语言带来了按契约设计编程的优势。
- 缺点
- 学习成本比较高:这似乎是许多契约式编程方法都面临的共同问题 —— 开发人员需要学习如何编写合适的契约。
- 契约可能增加代码复杂性。
- 开销:插件提供的运行时检查和二进制重写可能会增加程序的开销,影响性能。
在笔者的结对编程任务中,我们没有使用 .NET ,因此不具有这个契约式编程插件的应用场景。
9. 计算模块单元测试
结果展示
测试框架:google test,在windows下的clion中使用。
这里不能看第一行!因为引入了googletest
,导致对项目进行覆盖率统计时这个文件夹也被统计了进去,导致第一行的总覆盖率看起来非常糟糕。
其中,和实现功能相关的文件为 engine
和 paramParser
,全部计算在 engine.cpp 中完成,这个文件的分支覆盖率也达到了 89% 。其余文件的单元测试也尽力做了,可以查看下面的测试思路,把所有可能的输入情况都遍历到了,几乎所有行覆盖率都到达了 100% ,但是分支覆盖率提不上去。如 output.cpp
中只有一个用于输出的循环,在单元测试数据为循环 0 / 1 / 多次的情况下每行都 hit 了至少4次,依旧无法突破 33% 的分支覆盖率,疑为和该测试框架对分支覆盖率的判定有关。
测试思路
单元测试分为对paramParser、engine和output三个模块的测试。
读入与分词
封装了如下函数(只保留了主要代码):
void parseWordUnitTest(string input, int argc, char *argv[], char *wordAns[], int wordAnsLen, int *optAns) {
// read from file
//parse
parser.parseParams(argc, (const char **) argv, options);
// compare ans size
ASSERT_EQ(tmpAns.size(), wordAnsVector.size());
//compare content
for (auto &str: tmpAns) {
ASSERT_EQ(wordsSet.count(str), 1);
}
//compare options
for (int i = 0; i < 7; i++) {
ASSERT_EQ(options[i], optAns[i]);
}
}
测试样例:包含特殊字符,大小写字母和重复单词:
// parseWordUnitTest -c -j -r -h
TEST(Manual, T14) {
string input = "orz@OrZ#orz%zSO";
int argc = 8;
char *argv[10] = {"Wordlist.exe", "-c", "input.txt", "-r", "-j", "a", "-h", "o"};
char *wordAns[10] = {"orz", "zso"};
int wordAnsLen = 2;
int optAns[8] = {0, 0, 1, 'o', 0, 'a', 1, 0};
parseWordUnitTest(input, argc, argv, wordAns, wordAnsLen, optAns);
}
核心计算功能测试
手搓数据
这里对和接口交互封装了三个函数,下面以gen_chains_all
接口为例:
void test_gen_chain_all(const char *words, char *ans[], int ansLen) {
char **testRes = (char **) malloc(20005);
int testLen = gen_chains_all(words, testRes);
ASSERT_EQ(testLen, ansLen);
for (int i = 0; i < testLen; i++) {
ASSERT_EQ(strcmp(ans[i], testRes[i]), 0);
}
}
测试数据:
// -w -h -t
TEST(Manual, T8) {
char *words = "a ac*aD d D#bc cd bd\n";
char *ans[10] = {"a", "ac", "cd", "d"};
int ansLen = 4;
test_gen_chain_word(words, 'a', 'd', 0, true, ans, ansLen);
}
随机数据
我们还采用了生成随机数据,并和暴力对拍的策略进行测试。
void createData(int n, int *options) {
for (int i = 0; i < n; i++) {
string str;
while (true) {
str.clear();
int wordLen = rand() % 6 + 1;
for (int j = 0; j < wordLen; j++) {
int s = rand() % 26, t = rand() % 2, r = rand() % 2;
if (t) str += (s + 'a');
else str += (s + 'A');
if (r) str += "#";
}
if (!options[OP_R]) {
if (str.back() <= str.front()) {
continue;
}
}
}
randomWords.push_back(str);
}
}
用暴力对随机数据求解(主要代码):
void bruteForce(int n, int *options) {
int globalMaxAns = 0;
for (int i = 0; i < n; i++) {
if (options[OP_J] == randomWords[i].front()) {
continue;
}
if (!options[OP_H] || (options[OP_H] == randomWords[i].front())) {
randPaths.clear();
singlePath.clear();
memset(randVis, 0, sizeof(randVis));
singlePath.push_back(randomWords[i]);
randVis[i] = true;
dfs(i, options);
int maxAns = 0, maxIdx = 0;
for (int j = 0; j < randPaths.size(); j++) {
if (randPaths[j].size() < 2) {
continue;
}
if (options[OP_N]) {
string str;
for (auto &k: randPaths[j]) {
str += k;
str += " ";
}
randomGlobalAns.push_back(str);
} else {
if (options[OP_J]) {
for (auto &k: randPaths[j]) {
if (options[OP_J] == k.front()) {
continue;
}
}
}
if (options[OP_T]) {
string str = randPaths[j].back();
if (options[OP_T] != str.back()) {
continue;
}
}
if (options[OP_W]) {
if (randPaths[j].size() > maxAns) {
maxAns = randPaths[j].size();
maxIdx = j;
}
} else if (options[OP_C]) {
int charLen = 0;
for (auto &k: randPaths[j]) {
charLen += k.size();
}
if (charLen > maxAns) {
maxAns = charLen;
maxIdx = j;
}
}
}
}
if (options[OP_W] || options[OP_C]) {
if (maxAns > globalMaxAns) {
globalMaxAns = maxAns;
randomGlobalAns.clear();
for (auto &j: randPaths[maxIdx]) {
randomGlobalAns.push_back(j);
}
}
}
}
}
}
void randomTestEngine(int n, int *options, string input) {
// initialize
createData(n, options);
// build graph and prepare for topsort
bruteForce(n, options);
}
对比engine模块的输出和暴力得到的结果(主要代码)
void randomTestCmp(int *options) {
// initialize
int ans = engine(options, randomResult);
ASSERT_EQ(ans, randomGlobalAns.size());
if (!options[OP_N]) {
for (int i = 1; i < ans; i++) {
string str1 = charStarToString(randomResult[i - 1]);
string str2 = charStarToString(randomResult[i]);
ASSERT_EQ(str1.back(), str2.front());
}
}
if (options[OP_C]) {
int len1 = 0, len2 = 0;
for (int i = 0; i < ans; i++) {
len1 += strlen(randomResult[i]);
len2 += randomGlobalAns[i].size();
}
ASSERT_EQ(len1, len2);
}
// check for -h
// check for -t
// check for -j
}
测试数据(枚举所有可能的输入排列):
// -n
TEST(Random, T1) {
int options[8] = {1, 0, 0, 0, 0, 0, 0, 0};
for (int i = 0; i < 5; i++) {
Sleep(1000);
randomTestEngine(100, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -w
TEST(Random, T2) {
int options[8] = {0, 1, 0, 0, 0, 0, 0, 0};
for (int i = 0; i < 5; i++) {
Sleep(1000);
randomTestEngine(100, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -w -h
TEST(Random, T3) {
for (int i = 0; i < 5; i++) {
int h = rand() % 26;
int options[8] = {0, 1, 0, h + 'a', 0, 0, 0, 0};
Sleep(1000);
randomTestEngine(100, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -w -t
TEST(Random, T4) {
for (int i = 0; i < 5; i++) {
int t = rand() % 26;
int options[8] = {0, 1, 0, 0, t + 'a', 0, 0, 0};
Sleep(1000);
randomTestEngine(100, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -w -j
TEST(Random, T5) {
for (int i = 0; i < 5; i++) {
int j = rand() % 26;
int options[8] = {0, 1, 0, 0, 0, j + 'a', 0, 0};
Sleep(1000);
randomTestEngine(100, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -w -h -t
TEST(Random, T6) {
for (int i = 0; i < 10; i++) {
int h = rand() % 26;
int t = rand() % 26;
int options[8] = {0, 1, 0, h + 'a', t + 'a', 0, 0};
Sleep(1000);
randomTestEngine(100, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -w -h -j
TEST(Random, T7) {
for (int i = 0; i < 10; i++) {
int h = rand() % 26;
int j = rand() % 26;
int options[8] = {0, 1, 0, h + 'a', 0, j + 'a', 0};
Sleep(1000);
randomTestEngine(100, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -w -t -j
TEST(Random, T8) {
for (int i = 0; i < 10; i++) {
int t = rand() % 26;
int j = rand() % 26;
int options[8] = {0, 1, 0, 0, t + 'a', j + 'a', 0};
Sleep(1000);
randomTestEngine(100, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -w -h -t -j
TEST(Random, T9) {
for (int i = 0; i < 10; i++) {
int h = rand() % 26;
int t = rand() % 26;
int j = rand() % 26;
int options[8] = {0, 1, 0, h + 'a', t + 'a', j + 'a', 0};
Sleep(1000);
randomTestEngine(100, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -w -r
TEST(Random, T10) {
int options[8] = {0, 1, 0, 0, 0, 0, 1, 0};
for (int i = 0; i < 5; i++) {
Sleep(1000);
randomTestEngine(40, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -w -h -r
TEST(Random, T11) {
for (int i = 0; i < 5; i++) {
int h = rand() % 26;
int options[8] = {0, 1, 0, h + 'a', 0, 0, 1, 0};
Sleep(1000);
randomTestEngine(40, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -w -t -r
TEST(Random, T12) {
for (int i = 0; i < 5; i++) {
int t = rand() % 26;
int options[8] = {0, 1, 0, 0, t + 'a', 0, 1, 0};
Sleep(1000);
randomTestEngine(40, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -w -j -r
TEST(Random, T13) {
for (int i = 0; i < 5; i++) {
int j = rand() % 26;
int options[8] = {0, 1, 0, 0, 0, j + 'a', 1, 0};
Sleep(1000);
randomTestEngine(40, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -w -h -t -r
TEST(Random, T14) {
for (int i = 0; i < 10; i++) {
int h = rand() % 26;
int t = rand() % 26;
int options[8] = {0, 1, 0, h + 'a', t + 'a', 0, 1};
Sleep(1000);
randomTestEngine(40, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -w -h -j -r
TEST(Random, T15) {
for (int i = 0; i < 10; i++) {
int h = rand() % 26;
int j = rand() % 26;
int options[8] = {0, 1, 0, h + 'a', 0, j + 'a', 1};
Sleep(1000);
randomTestEngine(40, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -w -t -j -r
TEST(Random, T16) {
for (int i = 0; i < 10; i++) {
int t = rand() % 26;
int j = rand() % 26;
int options[8] = {0, 1, 0, 0, t + 'a', j + 'a', 1};
Sleep(1000);
randomTestEngine(40, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -w -h -t -j -r
TEST(Random, T17) {
for (int i = 0; i < 10; i++) {
int h = rand() % 26;
int t = rand() % 26;
int j = rand() % 26;
int options[8] = {0, 1, 0, h + 'a', t + 'a', j + 'a', 1};
Sleep(1000);
randomTestEngine(40, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -c
TEST(Random, T18) {
int options[8] = {0, 0, 1, 0, 0, 0, 0, 0};
for (int i = 0; i < 5; i++) {
Sleep(1000);
randomTestEngine(100, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -c -h
TEST(Random, T19) {
for (int i = 0; i < 5; i++) {
int h = rand() % 26;
int options[8] = {0, 0, 1, h + 'a', 0, 0, 0, 0};
Sleep(1000);
randomTestEngine(100, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -c -t
TEST(Random, T20) {
for (int i = 0; i < 5; i++) {
int t = rand() % 26;
int options[8] = {0, 0, 1, 0, t + 'a', 0, 0, 0};
Sleep(1000);
randomTestEngine(100, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -c -j
TEST(Random, T21) {
for (int i = 0; i < 5; i++) {
int j = rand() % 26;
int options[8] = {0, 0, 1, 0, 0, j + 'a', 0, 0};
Sleep(1000);
randomTestEngine(100, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -c -h -t
TEST(Random, T22) {
for (int i = 0; i < 10; i++) {
int h = rand() % 26;
int t = rand() % 26;
int options[8] = {0, 0, 1, h + 'a', t + 'a', 0, 0};
Sleep(1000);
randomTestEngine(100, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -c -h -j
TEST(Random, T23) {
for (int i = 0; i < 10; i++) {
int h = rand() % 26;
int j = rand() % 26;
int options[8] = {0, 0, 1, h + 'a', 0, j + 'a', 0};
Sleep(1000);
randomTestEngine(100, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -c -t -j
TEST(Random, T24) {
for (int i = 0; i < 10; i++) {
int t = rand() % 26;
int j = rand() % 26;
int options[8] = {0, 0, 1, 0, t + 'a', j + 'a', 0};
Sleep(1000);
randomTestEngine(100, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -c -h -t -j
TEST(Random, T25) {
for (int i = 0; i < 10; i++) {
int h = rand() % 26;
int t = rand() % 26;
int j = rand() % 26;
int options[8] = {0, 0, 1, h + 'a', t + 'a', j + 'a', 0};
Sleep(1000);
randomTestEngine(100, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -c -r
TEST(Random, T26) {
int options[8] = {0, 0, 1, 0, 0, 0, 1, 0};
for (int i = 0; i < 5; i++) {
Sleep(1000);
randomTestEngine(40, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -c -h -r
TEST(Random, T27) {
for (int i = 0; i < 5; i++) {
int h = rand() % 26;
int options[8] = {0, 0, 1, h + 'a', 0, 0, 1, 0};
Sleep(1000);
randomTestEngine(40, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -c -t -r
TEST(Random, T28) {
for (int i = 0; i < 5; i++) {
int t = rand() % 26;
int options[8] = {0, 0, 1, 0, t + 'a', 0, 1, 0};
Sleep(1000);
randomTestEngine(40, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -c -j -r
TEST(Random, T29) {
for (int i = 0; i < 5; i++) {
int j = rand() % 26;
int options[8] = {0, 0, 1, 0, 0, j + 'a', 1, 0};
Sleep(1000);
randomTestEngine(40, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -c -h -t -r
TEST(Random, T30) {
for (int i = 0; i < 10; i++) {
int h = rand() % 26;
int t = rand() % 26;
int options[8] = {0, 0, 1, h + 'a', t + 'a', 0, 1};
Sleep(1000);
randomTestEngine(40, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -c -h -j -r
TEST(Random, T31) {
for (int i = 0; i < 10; i++) {
int h = rand() % 26;
int j = rand() % 26;
int options[8] = {0, 0, 1, h + 'a', 0, j + 'a', 1};
Sleep(1000);
randomTestEngine(40, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -c -t -j -r
TEST(Random, T32) {
for (int i = 0; i < 10; i++) {
int t = rand() % 26;
int j = rand() % 26;
int options[8] = {0, 0, 1, 0, t + 'a', j + 'a', 1};
Sleep(1000);
randomTestEngine(40, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
// -c -h -t -j -r
TEST(Random, T33) {
for (int i = 0; i < 10; i++) {
int h = rand() % 26;
int t = rand() % 26;
int j = rand() % 26;
int options[8] = {0, 0, 1, h + 'a', t + 'a', j + 'a', 1};
Sleep(1000);
randomTestEngine(40, options, "");
//randomTestPrint();
randomTestCmp(options);
}
}
10. 计算模块异常处理
我们一共设计了13种异常,可以分为以下三类:
文件路径类
-
FILE_INVALID: 1
-
输入单词文本文件不合法,即没有以 .txt 扩展名结尾
-
ERROR: The file extension is illegal, please enter a file name ending with .txt .
-
TEST(FILE_BUG, FILE_INVALID) { try { paramParser parser = paramParser(); int argc = 3; char *argv[10] = {"Wordlist.exe", "-w", "input.txtt"}; int options[8]; parser.parseParams(argc, (const char **) argv, options); ASSERT_EQ(0, 1); } catch (bugReport &e) { ASSERT_EQ(e.getErrorFlag(), -1 * FILE_INVALID); } }
-
-
FILE_NONEXIST: 2
-
输入单词文本文件不存在,即路径错误问题
-
ERROR: The file does not exist, please check if the file path is correct.
-
TEST(FILE_BUG, FILE_NONEXIST) { try { paramParser parser = paramParser(); int argc = 3; char *argv[10] = {"Wordlist.exe", "-n", "noneExist.txt"}; int options[8]; parser.parseParams(argc, (const char **) argv, options); ASSERT_EQ(0, 1); } catch (bugReport &e) { ASSERT_EQ(e.getErrorFlag(), -1 * FILE_NONEXIST); } }
-
-
FILE_EMPTY: 3
-
输入的单词文本文件为空
-
ERROR: The input file is empty or doesn’t contain valid words.
-
TEST(FILE_BUG, FILE_EMPTY) { try { paramParser parser = paramParser(); int argc = 3; char *argv[10] = {"Wordlist.exe", "-n", "empty.txt"}; int options[8]; parser.parseParams(argc, (const char **) argv, options); ASSERT_EQ(0, 1); } catch (bugReport &e) { ASSERT_EQ(e.getErrorFlag(), -1 * FILE_EMPTY); } }
-
-
FILE_MISSING: 4
-
检测到 -n, -w, -c 但其后缺少输入文件的绝对路径
-
ERROR: Missing input file path.
-
TEST(FILE_BUG, FILE_MISSING) { try { paramParser parser = paramParser(); int argc = 2; char *argv[10] = {"Wordlist.exe", "-n"}; int options[8]; parser.parseParams(argc, (const char **) argv, options); ASSERT_EQ(0, 1); } catch (bugReport &e) { ASSERT_EQ(e.getErrorFlag(), -1 * FILE_MISSING); } }
-
-
FILE_FAIL_OUTPUT: 5
-
无法写输出文件到 solution.txt
-
ERROR: Fail to output the solution.
-
这里需要先把
solution.txt
设为只读 -
TEST(FILE_BUG, FILE_FAIL_OUTPUT) { try { int options[8] = {1, 0, 0, 0, 0, 0, 0, 0}; char *result[10] = {"output"}; output(options, result, 1); ASSERT_EQ(0, 1); } catch (bugReport &e) { ASSERT_EQ(e.getErrorFlag(), -1 * FILE_FAIL_OUTPUT); } }
-
选项异常类
-
PARAM_LACK
缺少必要的参数,包括以下类型:
-
PARAM_LACK_LETTER: 6
-
-h, -t, -j 后缺少字母
-
ERROR: Lack of a specified letter after -h, -t or -j.
-
TEST(PARAM_BUG, PARAM_LACK_LETTER) { try { paramParser parser = paramParser(); int options[8]; int argc = 4; char *argv[10] = {"Wordlist.exe", "-w", "input.txt", "-h"}; parser.parseParams(argc, (const char **) argv, options); ASSERT_EQ(0, 1); } catch (bugReport &e) { ASSERT_EQ(e.getErrorFlag(), -1 * PARAM_LACK_LETTER); } }
-
-
PARAM_LACK_OPT: 7
-
缺少 -n, -w, 或者 -c
-
ERROR: Lack of option, please choose one option from -n, -w and -c.
-
TEST(PARAM_BUG, PARAM_LACK_OPT) { try { paramParser parser = paramParser(); int options[8]; int argc = 3; char *argv[10] = {"Wordlist.exe", "-h", "a"}; parser.parseParams(argc, (const char **) argv, options); ASSERT_EQ(0, 1); } catch (bugReport &e) { ASSERT_EQ(e.getErrorFlag(), -1 * PARAM_LACK_OPT); } }
-
-
-
PARAM_CONFLICT
输入的参数选项不兼容
-
PARAM_CONFLICT_N: 8
-
输入的 -n 没有独立使用
-
ERROR: -n can not be used in combination with other options.
-
TEST(PARAM_BUG, PARAM_CONFLICT_N) { try { paramParser parser = paramParser(); int options[8]; int argc = 5; char *argv[10] = {"Wordlist.exe", "-n", "input.txt", "-h", "a"}; parser.parseParams(argc, (const char **) argv, options); ASSERT_EQ(0, 1); } catch (bugReport &e) { ASSERT_EQ(e.getErrorFlag(), -1 * PARAM_CONFLICT_N); } }
-
-
PARAM_CONFLICT_CW: 9
-
-w 和 -c 不能同时使用
-
ERROR: -w and -c can not be used in combination. Please choose one of them.
-
TEST(PARAM_BUG, PARAM_CONFLICT_CW) { try { paramParser parser = paramParser(); int options[8]; int argc = 5; char *argv[10] = {"Wordlist.exe", "-c", "input.txt", "-w", "input.txt"}; parser.parseParams(argc, (const char **) argv, options); ASSERT_EQ(0, 1); } catch (bugReport &e) { ASSERT_EQ(e.getErrorFlag(), -1 * PARAM_CONFLICT_CW); } }
-
-
PARAM_DUPLICATE: 10
-
同样的操作参数重复出现,比如 -n -n
-
多个文件参数,比如 [path1] [path2]
-
ERROR: Duplicate parameters were found
TEST(PARAM_BUG, PARAM_DUPLICATE) { try { paramParser parser = paramParser(); int options[8]; int argc = 5; char *argv[10] = {"Wordlist.exe", "-c", "input.txt", "-c", "input.txt"}; parser.parseParams(argc, (const char **) argv, options); ASSERT_EQ(0, 1); } catch (bugReport &e) { ASSERT_EQ(e.getErrorFlag(), -1 * PARAM_DUPLICATE); } }
-
-
PARAM_INVALID: 11
-
操作选项格式错误,不符合 -n, -w, -c, -r, -h, -t, -j 中的任意一种,比如 xyz 或者 -a
-
-h, -t, -j 后接的单字符不是字母(a-zA-Z)
-
ERROR: Non-existent parameter option. Please check the parameter format.
-
TEST(PARAM_BUG, PARAM_INVALID) { try { paramParser parser = paramParser(); int options[8]; int argc = 3; char *argv[10] = {"Wordlist.exe", "-x", "input.txt"}; parser.parseParams(argc, (const char **) argv, options); ASSERT_EQ(0, 1); } catch (bugReport &e) { ASSERT_EQ(e.getErrorFlag(), -1 * PARAM_INVALID); } }
-
-
运行时错误类
-
BUG_RING_EXIST: 12
-
操作选项中不含 -r 但构成了单词环
-
ERROR: The input data contains word rings.
-
TEST(RING_BUG, BUG_RING_EXIST) { try { int options[8] = {1, 0, 0, 0, 0, 0, 0, 0}; int ans = engine(options, result); ASSERT_EQ(0, 1); } catch (bugReport &e) { ASSERT_EQ(e.getErrorFlag(), -1 * BUG_RING_EXIST); } }
-
-
BUG_CHAIN_TOO_LONG: 13
-
计算出超过 20000 条单词链
-
ERROR: There are more than 20000 word chains.
-
这里使用的样例是:
-
aa ab ac ad ae af ag ah ai aj ak al am an ao ap aq ar as at au av aw ax ay az bb bc bd be bf bg bh bi bj bk bl bm bn bo bp bq br bs bt bu bv bw bx by bz cc cd ce cf cg ch ci cj ck cl cm cn co cp cq cr cs ct cu cv cw cx cy cz dd de df dg dh di dj dk dl dm dn do dp dq dr ds dt du dv dw dx dy dz ee ef eg eh ei ej ek el em en eo ep eq er es et eu ev ew ex ey ez ff fg fh fi fj fk fl fm fn fo fp fq fr fs ft fu fv fw fx fy fz gg gh gi gj gk gl gm gn go gp gq gr gs gt gu gv gw gx gy gz hh hi hj hk hl hm hn ho hp hq hr hs ht hu hv hw hx hy hz ii ii ik il im in io ip iq ir is it iu iv iw ix iy iz jj jk jl jm jn jo jp jq jr js jt ju jv jw jx jy jz
-
TEST(CHAIN_BUG, BUG_CHAIN_TOO_LONG) { try { paramParser parser = paramParser(); int options[8]; int argc = 3; char *argv[10] = {"Wordlist.exe", "-n", "input.txt"}; parser.parseParams(argc, (const char **) argv, options); int ans = engine(options, result); output(options, result, ans); ASSERT_EQ(0, 1); } catch (bugReport &e) { ASSERT_EQ(e.getErrorFlag(), -1 * BUG_CHAIN_TOO_LONG); } }
-
11. 界面模块设计过程
前言
那花狗是艺术家,不知死活的那一种,忽然发现造化少了一只鞋,就抵死要去把那缺憾补回来,一次一次把自己累得半死也不知停;黑狗却是哲学家,它在想,鞋子捡回来,又怎么样呢?又能怎么样呢?造化又不知安着什么心眼?拖鞋事件大约跟希腊神话里西西弗斯的那块石头,或中国神话里吴刚的那株桂树类同吧?这一场不知何时罢手的永恒重复,做了亦无所得,不做,亦无所失。每次它跑到岸边,脚趾触到温暖的海水,它就穷知究虑起来。它每想一次,疑团就更大,决定就更困难。看来生命是一场善意的圈套,在一带美丽的海滩上进行,你不知该怎么办。上当呢,是傻;不上当呢,是无趣。
出于美观、技术学习成本等角度的考虑,我们选择了使用 electron + vue.js
构造结对任务的 GUI ,并且在结对任务一开始时就提出了可视化单词链的美好构想 —— 只是事实证明我们成了上当的花狗,跨生态开发带来的麻烦事是我们在初期口嗨时完全没有预料到的,为了使 dll 文件成功与 GUI 对接,我们在无尽的版本适配、插件重装等“踩坑”过程上花费了 三倍 于具体编码的时间。但好在,历尽许多困难并得到了学长 p 的倾心帮助后,我们没有放弃,终于让项目刚开始时那句 “单词链这么明显的链条结构,做个可视化图应该还挺容易的” 的口嗨成为了现实。
当然,具体编码的部分确实没有什么难度,感觉 97% 的气力都花在了解决环境上。
技术框架
- 打包构建类
vue-cli-plugin-electron-builder 2.1.1
electron 13.0.0
- 注意:electron 版本高于 22 时将无法兼容 ffi-napi 插件,低于 9.0.0 时也会产生版本不兼容问题,目前发现 9.0.0、12.x、13.0.0 都是可以顺利构建项目的。
- 功能实现类
eCharts 5.4.1
: 实现单词链可视化vuetify 2.6.0
: 前端页面组件- 注意:构建初期,我们使用了 ant-design 作为前端组件,却遇到了 webpack 失效的问题(即 electron 无法正确构建应用程序)。当然,当时还有许多其他问题没有解决,因此不敢肯定是组件库涉及到的样式语言的原因,仅在此予以记录。
sass 1.32.0
: 前端页面组件样式语言- 使用该样式语言书写了一些自定义文件,某种程度上改进了 vuetify 不够精美的原生外观。
file-saver 2.0.5
: 实现文件保存ffi-napi 4.0.3
: 实现 dll 与 vue-electron 的数据交互
- 其他环境
node.js
:14.21.3- 注意:node.js 版本高于 16 、低于 14 都不可行。
Windows-build-tools 4.0.0
- 注意:直接使用 yarn 或者 npm 获取安装会因为包损坏问题失败(当然,更大的概率是先败在网络问题 / npm 无法识别 python 版本等问题上),此时需要去网络论坛中下载损坏包的光驱文件,并在该插件的全局文件夹下手动安装。如果试图自己安装 python2.7 和 Visual Studio Build Tools 都是会失败的。
界面设计
我们将 GUI 设计为如下所示的双栏排版:
- 左侧用于指定参数与编辑输入单词文本,其中,输入文本支持手动输入与从文件导入两种模式。
- 我们在前端添加了对文本内容(不允许为空)和参数组合(不允许冲突参数存在)的验证,便利了异常处理环节。下面展示 Script 部分的验证,其余参数冲突问题可以通过表单的特性解决(例如,设置
-n , -w, -c
处为单选框) -
genWordsChain() { let that = this; console.log({ rawWords: that.rawWords, calType: that.calType, hM: that.headLetterMust, tM: that.tailLetterMust, hN: that.headLetterNot, aR: that.allowRing }) let validateFlag = 1; if (that.rawWords === '') { that.emptyWordsAlert = true; validateFlag = 0; } if (that.calType === "1") { if (that.allowRing !== false || that.headLetterMust !== 'none' || that.headLetterNot !== 'none' || that.tailLetterMust !== 'none') { that.conflictParamsAlert = true; validateFlag = 0; } } if (that.headLetterMust === that.headLetterNot && that.headLetterMust !== 'none') { that.conflictParamsAlert = true; validateFlag = 0; } if (validateFlag === 1) { // ... }
- 我们在前端添加了对文本内容(不允许为空)和参数组合(不允许冲突参数存在)的验证,便利了异常处理环节。下面展示 Script 部分的验证,其余参数冲突问题可以通过表单的特性解决(例如,设置
- 右侧用于输出单词链计算结果,包括单词链数量、求解时间、可视化的单词链结果与导出结果文件选项。如果没有求得符合条件的单词链或者源文本在不允许环存在的场景下存在环,则会触发前端报错。
- 关于可视化单词链的设计,我们使用了 eCharts 插件完成节点关系图的生成功能,并且采用了以下设计方案(该配色方案同样是 GUI 的整体配色方案):
- 节点配色
- 单词链头:
#626c91
- 单词链尾:
#3fb1e3
- 单词链中段:
#6be6c1
- 单词链头:
- 节点大小: 单词长度 * 5
- 节点位置: 在画布内随机分布
- 节点配色
- Script 部分实现较长,可参见仓库源码,大体思路形如:
-
let idx = 0; for (let i = 0; i < subGraph.length; i++) { let tmpLink = subGraph[i]; let tmpLen = tmpLink.length; if (tmpLink[tmpLen - 1] === ' ') { tmpLink = tmpLink.slice(0, tmpLen - 1); } tmpLink = tmpLink.split(' '); for (let j = 0; j < tmpLink.length; j++, idx++) { let curWord = tmpLink[j]; data.push({ name: curWord, value: curWord.length, x: Math.random() * 30 + Math.random() * 5, y: Math.random() * 30 + Math.random() * 5, symbolSize: curWord.length * 5, id: idx, itemStyle: { color: (j === tmpLink.length - 1) ? '#3fb1e3' : (j === 0) ? '#626c91' : '#6be6c1', } }) if (j !== 0) { edges.push({ source: idx - 1, target: idx, }) } } }
- 关于可视化单词链的设计,我们使用了 eCharts 插件完成节点关系图的生成功能,并且采用了以下设计方案(该配色方案同样是 GUI 的整体配色方案):
需要注意的是,由于画图插件本身的性能限制问题,我们在进行压力测试时发现可视化单词链会在 -n
选项 + 15000 条单词链的情景下变得十分卡顿,但 solution.txt 文件可以正确输出 —— 这是一个我们未能优化的问题。
12. 界面模块与计算模块的对接
模块对接
此处我们使用了 ffi-napi
插件,以实现 node.js
生态与 dll 的链接。我们至今未知其根源的一个问题是:使用 CLion 打包的 dll 无法被 ffi-napi
正确识别(会报 Error 126 的错误),而使用 VisualStudio 打包则可以解决。同时,想要采用这个方案的同学还应该注意,dll 接口部分必须使用纯 C 实现,否则会导致模块无法被识别。(祭那个困扰了我们很久的 #include <string>
)
网络上该插件的使用都有比较详细的教程,只要使用如下的方式引入 dll ,就可以在方法中对其进行调用了:
const ffi = require('ffi-napi')
const myDll = ffi.Library('./core.dll', {
gen_chains_all: ['int',
['string']],
gen_chain_word: ['int',
['string', 'char', 'char', 'char', 'bool']],
gen_chain_char: ['int',
['string', 'char', 'char', 'char', 'bool']],
get_execution_time: ['double',
[]],
getResult: ['string',
[]]
})
// ...
let curNum = myDll.gen_chain_word(that.rawWords + '\x1a',
that.headLetterMust === 'none' ? 0 : that.headLetterMust.charCodeAt(0),
that.tailLetterMust === 'none' ? 0 : that.tailLetterMust.charCodeAt(0),
that.headLetterNot === 'none' ? 0 : that.headLetterNot.charCodeAt(0),
that.allowRing);
// ...
需要注意的是,需要在输入的文本后手动加上一个文件结束符 \x1a
才能正确计算结果。(由于 JavaScript 本身的特性,加上一个换行符 \n
也能解决这个问题)
以及我们至今没有解决的一个问题是函数参数的更新问题,比如,如果像作业要求中的接口示例那样将 result[]
写在函数参数中,该字符串值则不会得到正确更新 —— 尝试了几种改进方案之后,要么获得的是无法转换的地址值,要么是由于没有正确操作内存导致的乱码。因此我们单独开发了一个获取结果字符串的接口 getResult()
。这不是一个很好的方案,因此我们也希望得到问题的正确解答。
实现效果展示
实现效果展示
13. 结对过程简述
我们选择在主北 4 楼尽头的公共学习区完成结对编程。在此感谢楼上新开的 wings 咖啡,还有学习区桌上被 Gxy 把叶子翻过来卷过去无数次还默默吸收着电脑辐射的绿萝和君子兰。
结对照片如下:(左:Gxy,右:Yyy)
14. 结对编程总结
结对编程优缺点
- 优点
- 较大程度上可以改善代码质量,伙伴的审查往往能及时发现代码中的问题,避免埋下后患(虽然这个发现问题的效果是有限的,所谓的后患有时依旧会发生)。
- 一定程度上增强了沟通和知识共享的水平,减少了信息交流和理解上的问题,Yyy 具有创意的抽象思维时常启发笔者这个算法费勿。
- 缺点
- 需要相互适应:两个程序员之间有不同的工作风格和作息习惯,需要相互适应才能顺利合作。非常抱歉笔者的阴间作息带坏了 Yyy 。
- 笔者个人感觉结对编程导致自己花费了更多的时间和精力来完成任务,增加了成本和时间开销,如果一个人完成这个任务可能双方都没必要花这么多时间 —— 当然也有单词链任务本身的复杂度限制原因。
结对成员优缺点
- Gxy
- 优点
- 对前端框架部分的编码十分熟练,在 GUI 开发部分贡献巨大
- 学习能力强,对于新技术/新编辑器环境等都能很快通过文档上手并投入使用
- 解决问题能力强,能快速定位问题所在并找到解决方法
- 理解能力强,沟通效率高,开发速度快
- 具有令人放心甚至惊羡的审美
- 缺点
- 算法竞赛方面的基础比较薄弱,因此在计算模块书写和性能提升部分起到的作用不多(简直就没什么作用,蚌)
- 作息和心态都比较阴间,一定程度上带坏了队友
- 优点
- Yyy
- 优点
- 对计算模块部分的算法比较熟悉,因此在 engine 开发部分贡献非常非常大
- 对 C++ 工程的构建比较熟悉,比如 CMakeList.txt 的书写
- 信息搜集与整合能力较高
- 心态良好健康,能够为阴暗的队友提供积极的情绪支持,提高开发的可持续发展程度
- 对自己要求高,时常在队友想要摆烂的时候提出优化算法 / 项目框架 / 接口结构的需求,并且经常能给出具有建设性和创意的建议
- 缺点
- 效率低下,经常用大量时间解决愚蠢问题
- 最喜欢的解决问题方式:遇到困难睡大觉
- 环境配置杀手,指总是能掉进连环坑中,包括但不限于自己看串行或者各种版本问题
- 到处不初始化,量产让队友误以为是多线程的bug
- 优点