软件工程结对项目
项目 | 内容 |
---|---|
这个作业属于哪个课程 | 2023年北航敏捷软件工程社区-CSDN社区云 |
这个作业的要求在哪里 | 结对项目-最长英语单词链-CSDN社区 |
我在这个课程的目标是 | 学习与掌握软件工程的理论与应用实践,学习如何工程化地构建软件 |
这个作业在哪个具体方面帮助我实现目标 | 尝试结对编程,感受其优缺点,提升自身软件工程能力。 |
项目地址
- 教学班级:周四下午
- 计算模块和CLI:https://gitee.com/aaicy64/word_list_core/
- GUI:https://gitee.com/aaicy64/word_list_ui
PSP
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 60 | |
· Estimate | · 估计这个任务需要多少时间 | 60 | |
Development | 开发 | 1680 | |
· Analysis | · 需求分析 (包括学习新技术) | 180 | |
· Design Spec | · 生成设计文档 | 120 | |
· Design Review | · 设计复审 (和同事审核设计文档) | 60 | |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 60 | |
· Design | · 具体设计 | 180 | |
· Coding | · 具体编码 | 600 | |
· Code Review | · 代码复审 | 180 | |
· Test | · 测试(自我测试,修改代码,提交修改) | 300 | |
Reporting | 报告 | 540 | |
· Test Report | · 测试报告 | 180 | |
· Size Measurement | · 计算工作量 | 60 | |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 300 | |
合计 | 2280 |
接口设计
Information Hiding
信息隐藏主要体现在隐藏实现细节,有限地暴露接口上。这有助于降低系统的复杂度,简化接口。具体到本项目上:
Graph类
class Graph {
public:
explicit Graph(std::set<std::string>& words);
~Graph();
bool has_cycle();
int cnt_chains(std::vector<std::vector<const std::string*>>& chains);
// if not used, input '\0'
int longest_chain(char op_h, char op_t, char op_j, bool is_word, std::vector<std::string>& chain);
// if not used, input '\0'
int longest_chain_r(char op_h, char op_t, char op_j, bool is_word, std::vector<std::string>& chain);
// 下面是private声明
Graph类承担了计算任务的核心部分,对于调用者而言,无需关心里面具体的算法实现,只需要传入单词,构造这个类,计算得到相应结果即可。
OptionParser类
class OptionParser{
public:
/**
* 处理命令行参数
* @param argc main函数传入的参数
* @param argv main函数传入的参数
* @param options 选项
* @return
*/
static int parse(int argc, char* argv[], Options& options);
};
struct Options{
CountType countType;
char beginWith; // ‘\0'如果未指定,下同
char endWith;
char notBeginWith;
bool isCycleAllowed;
const char *filename;
}
enum class CountType{
NotSpecified,
WordChainCount, // 计算单词文本中可以构成多少个单词链
MaxWordLengthWordChain, // 计算最多单词数量的单词链
MaxAlphabetLengthWordChain, // 计算字母最多的单词链
};
其隐藏了处理命令行参数的细节,提供了得到Options
结构体的接口。
API
DLLEXPORT
int gen_chain_word(char *words[], int len, char *result[], char head, char tail, char no_begin, bool enable_loop);
DLLEXPORT
int gen_chains_all(char *words[], int len, char *result[]);
//__declspec(dllexport)
//int gen_chain_word_unique(char *words[], int len, char *result[]);
DLLEXPORT
int gen_chain_char(char *words[], int len, char *result[], char head, char tail, char no_begin, bool enable_loop);
/**
* 分配result的资源,这只分配char*的数组,但不为每个char*分配空间
* @return 地址
*/
DLLEXPORT
char **alloc_result();
/**
* 释放result的资源
* @param result
* @return
*/
DLLEXPORT
int free_result(char **result, int len);
对于CLI和GUI而言,能使用的只有core.dll
暴露出来的上述接口(这些函数在内部初始化Graph和调用其上的方法。)
Interface Design
接口设计即上述实现的4个建议的接口(为了方便替换模块)。额外的两个内存分配相关的函数是可选的,也可以由调用者管理相应的内存。
错误相关接口
//错误信息缓冲区,(显然线程不安全)
extern "C" DLLEXPORT char error_msg[256];
extern "C" DLLEXPORT int error_flag; //错误标志位
/**
* 把错误信息放入缓冲区,给GUI使用
* @param msg 错误信息
*/
inline void push_err(const std::string& msg) {
strcpy_s(error_msg, 256,msg.c_str());
error_flag = 1;
}
#ifdef USE_EXCEPTION
#include "../exceptions/WordListException.h"
#define ERR(ERR_CLASS,MSG) throw ERR_CLASS(MSG)
#else
#define ERR(ERR_CLASS,MSG) push_err(#ERR_CLASS MSG)
#endif
调用计算模块的程序不一定是C++(比如JavaScript或者Dart实现的界面),也就不能使用C++的异常。而给出的四个接口的返回值已经有意义,不能作为异常码,所以可选的采用把错误信息放入一段缓冲区的方式来传递错误。
Loose Coupling
松耦合要求减少模块之间的依赖,防止修改一个模块,结果整个程序都需要修改的情况发生,使得程序更加易于维护。而良好的接口设计是实现上述特性的根本。
对于本项目而言,由于计算模块,CLI(包括OptionParser等),GUI(甚至不是C++写的)相互之间相对独立,所以可以相对容易地修改。比如要优化计算模块的算法,只要接口一致,其他模块都不需要修改;或者要修改GUI也不需要修改算法部分。试想如果把算法实现全部写在按钮的回调函数里面(界面和逻辑高度耦合),那么修改和维护将会变得非常困难。
计算模块接口的设计与实现过程
代码组织
src
├── Graph.cpp
├── Graph.h
├── OptionParser.cpp
├── OptionParser.h
├── api.cpp
├── api.h
├── cli.cpp
├── cli.h
├── common.h
├── exceptions
│ ├── WordListException.cpp
│ └── WordListException.h
├── main.cpp
└── utils
├── errors.cpp
├── errors.h
├── utils.cpp
└── utils.h
Graph
实现了核心的计算逻辑。OptionParser
实现了对命令行参数的解析。api
是供CLI和GUI使用的接口。common.h
控制不同平台环境可能不同的CPP库函数和导出语法。exceptions
文件夹下声明了CPP的异常。utils/errors
包含了错误处理相关的功能(GUI部分不能使用CPP异常)。utils/utils
包含了一些工具函数。
计算模块接口设计和实现
-
设计
接口设计如下
// 返回所有的单词链 int gen_chains_all(char* words[], int len, char* result[]); // 返回按单词计算的最长单词链 int gen_chain_word(char* words[], int len, char* result[], char head, char tail, char reject, bool enable_loop); // 返回按字符计算的最长单词链 int gen_chain_char(char* words[], int len, char* result[], char head, char tail, char reject, bool enable_loop);
该问题核心在于构建一个图,26个字母为顶点,单词作为连接点的边,从单词首字母指向单词末尾字母。
单词链就是图上的路径的边序列,每条边只能走一次。
-
实现
构造
Graph
类,输入单词序列建图,按需求构造以下函数:// 判断是否有环 bool has_cycle(); // 获取所有单词链 int cnt_chains(std::vector<std::vector<const std::string*>>& chains); // 获取最长的单词链(无环图) int longest_chain(char op_h, char op_t, char op_j, bool is_word, std::vector<std::string>& chain); // 获取最长的单词链(有环图) int longest_chain_r(char op_h, char op_t, char op_j, bool is_word, std::vector<std::string>& chain);
判断是否有环,可以采用拓扑排序的方法,如果有点有单词进入,但未被访问到说明有环。
无环图上求解,可以采用类似拓扑排序的方法,每次从入度为0的点出发,更新下一个点的最长距离。
如果图上有环,成为一个NP问题,除了 dfs 搜索没有找到特别好的办法。
编译器编译通过无警告截图
UML
上面的UML包括了主要的结构,为了清晰没有包含异常类和一些工具类。
计算模块接口部分的性能改进
不带环情况
不带环情况由于是有向无环图,有渐进复杂度O(n)
的算法,且测试运行时间在1s之内,大概没有太多优化空间。
带环情况(-r)
带环情况似乎是NP问题,只有朴素的dfs方法,针对算法的优化大概只有剪枝(贪心?),但是我们对于算法方面没有很强的理解,于是考虑一些其他的优化。
使用VS性能探测工具分析热点代码
在-w
,-r
参数下运行50个单词。
热点不出所料为dfs的递归过程。
path vector容器
发现vector::pop_back
被标红,推测可能是vector容器重新分配内存的问题。于是加入如下代码先调用vector::reserve
防止reallocate(基于-r的数据约束<100单词)。
int gen_chain_word(char **words, int len, char **result, char head, char tail, char no_begin, bool enable_loop) {
std::set<std::string> wordsVec;
std::vector<std::string> chain;
chain.reserve(128); //先分配内存
...
多线程优化
使用OpenMP可以较为简单直观地进行多线程优化。
omp_set_num_threads(26);
#pragma omp parallel
{
int i = omp_get_thread_num();
if (i + 'a' != op_j) {
dfs_r(i, 0, vis[i], tmp_path[i]);
}
}
vis set容器(减少动态内存分配)
vis
标记了dfs时已经访问过的边(单词),之前实现使用了STL的set(因为对C++不了解,误以为其实现是hashmap,然而一般是红黑树)。但是边(单词)实际上是固定的,完全可以使用数组来实现,改为用如下数据结构实现:
struct IntSet {
int *arr;
int n;
void init(int _n) {
arr = new int[_n];
memset(arr, 0, sizeof(int) * _n);
n = _n;
}
void dispose() {
delete[] arr;
}
void insert(int x) {
arr[x] = 1;
}
void erase(int x) {
arr[x] = 0;
}
int count(int x) {
return arr[x] == 0 ? 0 : 1;
}
};
(insert,erase,count都是set的方法,这样写可以减少代码修改,可能也算一种contract?)
剪枝
在当前节点搜索出边时,遍历下一个节点搜索,而不是直接遍历边。也就是说在搜索时优先搜索较长的边,且每个下一节点只搜索一次,在回退时,同一个节点不会进入多次,造成无用的搜索。
优化效果
针对一组69个单词的数据,使用-w,-r参数。
优化前
优化后
可以看到优化效果较为显著。
消除线程间共享内存,从而不需要锁
在线程间无共享内存的情况下,可以不需要频繁的上锁,释放锁操作,可以显著地提高性能(有锁可能性能退化为单线程)。在试验中发现如果采用之前有锁的算法,无法在合理的时间内得出答案(>10min)。
Design by Contract,Code Contract
Design by Contract
契约式编程要求为程序接口指定规范的、精确的、可验证的规格,包括前置条件、后置条件、不变量等。在面向对象课程中我们使用了Java的JML来实现契约式编程。
优点
确定了接口的规格,保证程序在各个阶段处于正确的状态(通过不变量保证),保证接口在正确的(包括指定的异常情况)输入条件下能够得到预期的结果,使得程序更加健壮。
缺点
规范的规格书写较为难以书写,对程序员要求高,增加了代码的维护成本。缺乏配套的工具,规格的语法缺少统一化,在不同的项目中难以兼容。
现代程序设计语言可能在一些方面将契约式编程融入了其设计中,比如空安全,
int? a; // nullable
int a; // not nullable
Option<i32> a; //nullable
i32 a; // not nullable
在本次结对项目中,由于时间有限,没有使用规范的契约式编程。但是通过注释的方式解释了接口参数和返回值的意义:
/**
* 计算**最多单词数量**的最长单词链,其中前三个参数已经在上文进行了说明,
* `head`和`tail`分别为单词链**首字母**与**尾字母**约束(如果传入0,表示没有约束),
* 当`enable_loop`为`true`时表示**允许**输入单词文本中隐含“单词环”
* @param words
* @param len
* @param result 由gen_*函数分配内存,使用后需要释放
* @param head
* @param tail
* @param enable_loop 允许有环
* @return 解长度
*/
DLLEXPORT
int gen_chain_word(char *words[], int len, char *result[], char head, char tail, char no_begin, bool enable_loop);
Code Contract
Code Contract是微软dotnet框架的契约式编程框架(但是在新版本上已经停止支持了)。
Code contracts aren’t supported in .NET 5+ (including .NET Core versions). Consider using Nullable reference types instead.
相比JML,Code Contract提供了和dotnet平台语言统一的契约式编程接口(而不是在注释里的另一种语言),但是它的停止支持似乎也证实纯粹的契约式编程可能应用范围不太广,但是上面提到的“可空引用类型”可能也是契约式编程思想的融入。
单元测试
使用了gtest框架,针对OptionParser
(CLI命令行参数处理)和Graph
核心计算模块分别构造在各种情况下(正常和异常等)的测试用例。
覆盖率
在WSL下使用lcov进行覆盖率测试,为了得到有指导意义的覆盖率,需要使用命令行选项:
--rc lcov_branch_coverage=1 # 打开分支覆盖率
--rc geninfo_no_exception_branch=1 # 移除异常(throw)附近编译器插入的分支
--rc lcov_excl_line='delete.*' # 移除delete附近编译器插入的分支
对于throw
,编译器会在附近插入额外的用于错误处理的分支。
对于delete
,实际编译器会生成如下代码:
# if (m) {
# m->~MyInterface(); // deleting destructor
#}
覆盖率结果(使用genhtml生成html报告):
此外,使用valgrind
检查了内存泄漏和不安全操作等。
计算模块部分异常处理说明
有如下几种异常:
class DLLEXPORT WordListException : public runtime_error {
public:
explicit WordListException(const std::string &error);
};
/**
* 文件IO错误
*/
class DLLEXPORT FileException : public WordListException {
public:
explicit FileException(const std::string &error);
};
/**
* 命令行接口错误
*/
class DLLEXPORT CLIOptionException : public WordListException {
public:
explicit CLIOptionException(const std::string &error);
};
/**
* API错误,比如答案太长
*/
class DLLEXPORT APIException : public WordListException {
public:
explicit APIException(const std::string &error);
};
三种异常都继承自WordListException
,而WordListException
继承std::runtime_error
。
计算模块的异常为APIException
,错误种类由what()
方法返回的字符串区分。
用于测试的宏:
#define EXPECT_THROW_WITH_EXACT_MSG(f, t, msg) \
try{ \
f; \
FAIL(); \
}catch(t& e) { \
ASSERT_STREQ(msg,e.what()); \
}
APIException
-n参数不支持-r(有环)
TEST(APITest, ErrTest1) {
const char *words1[]{"axb", "bxa"};
char **result1 = alloc_result();
EXPECT_THROW_WITH_EXACT_MSG(gen_chains_all(const_cast<char **>(words1), 2, result1),
APIException,
"word list has cycle without specify `enable_loop(-r)`(-n not support).");
free_result(result1, 0);
}
-w,-c参数在有环的情况下没有指定-r
TEST(APITest, ErrTest3) {
const char *words1[]{"axb", "bxa"};
char **result1 = alloc_result();
EXPECT_THROW_WITH_EXACT_MSG(gen_chain_word(const_cast<char **>(words1), 2, result1,
'\0', '\0', '\0', false),
APIException,
"word list has cycle without specify `enable_loop(-r)`.");
free_result(result1, 0);
}
TEST(APITest, ErrTest4) {
const char *words1[]{"axb", "bxa"};
char **result1 = alloc_result();
EXPECT_THROW_WITH_EXACT_MSG(gen_chain_char(const_cast<char **>(words1), 2, result1,
'\0', '\0', '\0', false),
APIException,
"word list has cycle without specify `enable_loop(-r)`.");
答案太长(>20000)
TEST(APITest, ErrTest2) {
char **words1{new char *[10000]};
int cnt = 0;
for (int i = 0; i < 26; ++i) {
for (int j = i + 1; j < 26; ++j) {
for (int k = 0; k < 1; ++k,++cnt) {
char *t = new char[4];
t[3] = '\0';
t[0] = 'a' + i;
t[1] = 'a' + k;
t[2] = 'a' + j;
words1[cnt] = t;
}
}
}
char ** result1 = alloc_result();
int r;
EXPECT_THROW_WITH_EXACT_MSG(r=gen_chains_all(words1, cnt, result1),
APIException,
"result too long.");
free_result(result1,0);
for (int i = 0; i < cnt; ++i) {
delete[] words1[i];
}
delete[] words1;
}
FileException
无法打开输入文件
TEST(FileTest, Test1) {
//const char *file = "notExist.txt";
const char *args[]{"cli.exe", "-w", "notExist.txt"};
EXPECT_THROW_WITH_EXACT_MSG(myMain(3, const_cast<char **>(args)), FileException, "failed to open file:notExist.txt");
}
还有无法打开输出文件抛出的异常,但是不好构造测试,因为出现这种情况可能是权限不足或者文件系统故障或被其他程序占用,不像文件不存在那么常见。
std::ofstream solution{"solution.txt"};
if (!solution.is_open()) {
throw FileException("failed to open file `solution.txt` for output");
}
CLIOptionException
文件扩展名不是txt
TEST(OptionParserTest, ErrTest1) {
Options options{};
const char *args[]{"cli.exe", "-w", "1"};
EXPECT_THROW_WITH_EXACT_MSG(OptionParser::parse(3, const_cast<char **>(args), options),
CLIOptionException,
"file name invalid, should be txt.");
}
参数太少
TEST(OptionParserTest, ErrTest2) {
Options options{};
const char *args[]{"cli.exe"};
EXPECT_THROW_WITH_EXACT_MSG(OptionParser::parse(1, const_cast<char **>(args), options),
CLIOptionException,
"too few options");
}
无效的参数
比如-gg
TEST(OptionParserTest, ErrTest3) {
Options options{};
const char *args[]{"cli.exe", "-gg", "233"};
EXPECT_THROW_WITH_EXACT_MSG(OptionParser::parse(3, const_cast<char **>(args), options),
CLIOptionException,
"invalid option");
}
重复的功能选项
输入了两次-n
TEST(OptionParserTest, ErrTest4) {
Options options{};
const char *args[]{"cli.exe", "-n", "1.txt", "-n", "2.txt"};
EXPECT_THROW_WITH_EXACT_MSG(OptionParser::parse(5, const_cast<char **>(args), options),
CLIOptionException,
"file name has been specified more than once");
}
-n和其他选项一起使用
TEST(OptionParserTest, ErrTest11) {
Options options{};
const char *args[]{"cli.exe", "-n", "1.txt", "-r"};
EXPECT_THROW_WITH_EXACT_MSG(OptionParser::parse(4, const_cast<char **>(args), options),
CLIOptionException,
"-n should not be used with other options.");
}
没有指定输入文件
TEST(OptionParserTest, ErrTest12) {
Options options{};
const char *args[]{"cli.exe", "-j", "a", "-t", "a", "-r"};
EXPECT_THROW_WITH_EXACT_MSG(OptionParser::parse(6, const_cast<char **>(args), options),
CLIOptionException,
"no file specified.");
}
为-h,-t,-j指定了错误的参数
TEST(OptionParserTest, ErrTest13) {
Options options{};
const char *args[]{"cli.exe", "-w", "1.txt", "-h", "aa"};
EXPECT_THROW_WITH_EXACT_MSG(OptionParser::parse(5, const_cast<char **>(args), options),
CLIOptionException,
"should specify only ONE character for -h.");
}
重复的额外选项
TEST(OptionParserTest, ErrTest16) {
Options options{};
const char *args[]{"cli.exe", "-w", "1.txt", "-j", "a", "-j", "a"};
EXPECT_THROW_WITH_EXACT_MSG(OptionParser::parse(7, const_cast<char **>(args), options),
CLIOptionException,
"-j option has been specified more than once.");
}
无法识别的参数
比如-g
TEST(OptionParserTest, ErrTest20) {
Options options{};
const char *args[]{"cli.exe", "-w", "1.txt", "-g", "c"};
EXPECT_THROW_WITH_EXACT_MSG(OptionParser::parse(5, const_cast<char **>(args), options),
CLIOptionException,
"option not recognized.");
}
(-j,-h,-t)的参数只能是[a-zA-Z]
TEST(OptionParserTest, ErrTest21) {
Options options{};
const char *args[]{"cli.exe", "-w", "1.txt", "-h", ";"};
EXPECT_THROW_WITH_EXACT_MSG(OptionParser::parse(5, const_cast<char **>(args), options),
CLIOptionException,
"only [a-zA-Z] is allowed for option.");
}
界面模块设计过程
界面使用Flutter框架实现。Flutter是跨平台的GUI框架,只需要少量的配置即可生成美观的界面,相比Qt而言默认配置下的界面更为美观,与基于electron开发web界面相比,打包体积更小(20MB << 200+MB)。
Flutter 是 Google 开源的应用开发框架,仅通过一套代码库,就能构建精美的、原生平台编译的多平台应用。
Flutter 代码可以直接编译成 ARM 或 Intel 平台的机器代码,以及 JavaScript 代码,确保了 Flutter 应用能够拥有原生平台的性能表现。
Flutter是声明式的GUI框架,这提高了开发效率和可维护性。
在声明式风格中,视图配置(如 Flutter 的 Widget )是不可变的,它只是轻量的「蓝图」。要改变 UI,widget 会在自身上触发重建(在 Flutter 中最常见的方法是在
StatefulWidget
上调用setState()
)并构造一个新的 Widget 子树。
左侧部分的下拉菜单和选项代替了CLI的命令行参数部分,可以看到在单词链计数-n
模式下,允许单词成环等功能都不可勾选或选择,且选择单词链开头结尾等限制时使用下拉菜单而不是让用户自行输入,这在GUI保证了输入的合法。
例如,只有_countType != CountType.wordChainCount
(不是统计单词链总数)时,单词链结尾字母
指定菜单才可以使用。
Row(
children: [
const Text("单词链结尾字母"),
DropdownButton<String>(
value: _endWith,
items: _countType != CountType.wordChainCount
? alphabet
.map((e) => DropdownMenuItem<String>(
value: e, child: Text(e)))
.toList() +
[
const DropdownMenuItem(
value: null, child: Text("无"))
]
: [],
onChanged: (t) {
setState(() {
_endWith = t;
});
},
hint: const Text("单词链结尾字母"),
disabledHint: const Text("不支持的选项"),
icon: const Icon(Icons.arrow_drop_down_sharp),
elevation: 16,
style: const TextStyle(color: Colors.deepPurple),
underline: Container(
height: 2,
color: Colors.deepPurpleAccent,
),
),
Text(_endWith != null ? '以$_endWith作为单词链结尾字母' : '未指定')
],
),
右侧部分的单词输入框可以手动输入或者从文件打开。
点击“选择单词文件打开”按钮会打开系统选择文件对话框,这是通过用于跨平台文件操作的file_selector
包实现的。
final XFile? file = await openFile(
acceptedTypeGroups: <XTypeGroup>[textTypeGroup]);
var str = await file?.readAsString();
const XTypeGroup textTypeGroup = XTypeGroup(
label: 'text',
extensions: <String>[
'txt',
],
);
同时限制了只能打开txt
文件。下面的保存文件结果同理,会将结果保存到solution.txt
文件。
点击求解按钮后,结果会显示在下方文本框,且会显示求解时间,如下图:
点击清除结果,可以清空结果文本框。
界面模块和计算模块的对接
计算模块
DLLEXPORT
int gen_chain_word(char *words[], int len, char *result[], char head, char tail, char no_begin, bool enable_loop);
DLLEXPORT
int gen_chains_all(char *words[], int len, char *result[]);
DLLEXPORT
int gen_chain_char(char *words[], int len, char *result[], char head, char tail, char no_begin, bool enable_loop);
DLLEXPORT
char **alloc_result();
DLLEXPORT
int free_result(char **result, int len);
在api.h
声明相应接口(为了方便和其他组替换模块,使用了建议的声明),宏DLLEXPORT
根据平台展开为导出符号的相应声明。
建议的声明没有指定内存的分配方式,于是新增了两个用于分配和释放内存的接口。虽然这可能会导致一定的不统一(内存分配方式不同),但是可以进行一些修改以适配。
在对应Windows平台的CMakeLists里加入编译core.dll
的命令:
GUI
使用Dart的FFI功能调用C++编写的计算模块。
加载动态库:
const libraryPath = 'core.dll';
const pointerSize = 8; // 64位指针
const maxResultSize = 20000;
final dynlib = ffi.DynamicLibrary.open(libraryPath);
加载函数(例如gen_chain_word
):
final GenChainType genChainWordN = dynlib
.lookup<ffi.NativeFunction<GenChainNativeType>>('gen_chain_word')
.asFunction(isLeaf: true);
封装成Dart函数:
Future<List<String>> genChainsAll(List<String> words) async {
List<String> ret = await Isolate.run(() {
var result = allocResult();
var r = genChainsAllN(
_words2Native(words),
words.length,
result,
);
if (errFlag.value > 0) {
errFlag.value = 0;
throw 'core return err: ${errMsg.toDartString()}';
}
var ret = _native2Words(result, r);
freeResult(result, r);
return ret;
});
return ret;
}
Isolate是Dart提供的并发功能,将计算代码在一个Isolate中执行,以防止在GUI线程中执行CPU密集代码卡死GUI(在计算时会显示加载中指示CircularProgressIndicator
,转圈圈加载)。
const Text("solution:"),
_isCalculating
? const CircularProgressIndicator()
: SizedBox(
height: 198,
width: 398,
child: Card(
elevation: 2.0,
child: Text(_solution ?? ""),
),
),
异常
因为GUI使用dart编写,通过FFI调用C++代码实现的计算模块,而C++的异常无法使用FFI简单地对接,于是用下述方式解决:
//错误信息缓冲区
extern "C" DLLEXPORT char error_msg[256];
extern "C" DLLEXPORT int error_flag; //错误标志位
/**
* 把错误信息放入缓冲区,给GUI使用
* @param msg 错误信息
*/
inline void push_err(const std::string& msg) {
strcpy_s(error_msg, 256,msg.c_str());
error_flag = 1;
}
#ifdef USE_EXCEPTION
#include "../exceptions/WordListException.h"
#define ERR(ERR_CLASS,MSG) throw ERR_CLASS(MSG)
#else
#define ERR(ERR_CLASS,MSG) push_err(#ERR_CLASS MSG)
#endif
如果使用可以使用C++异常的GUI,则计算模块若出错将抛出异常;否则,计算模块若出错将置错误标志位,并将出错信息放入缓冲区。
对应的GUI部分代码:
typedef ErrMsgType = Utf8;
final ffi.Pointer<ErrMsgType> errMsg = dynlib.lookup<ErrMsgType>('error_msg');
final ffi.Pointer<ffi.Int> errFlag = dynlib.lookup<ffi.Int>('error_flag');
//如果出错……
if (errFlag.value > 0) {
errFlag.value = 0;
throw 'core return err: ${errMsg.toDartString()}'; // 这是Dart的异常
}
相应错误信息通过上述方式可以在GUI上显示。
结对过程
使用Clion的Code With Me功能配合腾讯会议语音进行结对编程。
结对编程
优点
- “领航员”角色可以不断地检查代码,提醒编程实施者可能出现的问题,比如在本次项目中,指出了我忽略了
char
类型的符号问题(char - type for character representation which can be most efficiently processed on the target system
未指定有没有符号)等。 - 提高编程效率,减少划水的可能,在clion的code with me界面光标数分钟不动一定会被对方发现。
- 提高代码质量,写出低效或者有bug的代码有很大概率被看出来并立即提醒。
- 提高解决问题的能力,两个人可以一起思考算法。
缺点
- 略微有些费人力,毕竟传统一个人的工作给到两个人,可能增加了两个人的连续工作时间,但是长期来看是省时间的(测试等的时间)。
- 这次项目的GUI框架采用Flutter,而天瑞对此并不熟悉,所以并不能很好地结对开发(选择此框架的原因是时间有限而这是我们最有把握在短时间内完成项目的框架)。在这种有一个人对开发技术不太熟悉的情况下,结对开发有其局限性。
- 由于各自允许的时间范围不同(需要负责不同的项目/选修了不同的课程),可能导致互相等待,降低时间效率。
结对评价
姓名 | 优点 | 缺点 |
---|---|---|
马天瑞 | 注重细节,能够察觉代码中的细节问题,注意一些边界条件;注重测试,对计算模块的各个方面,各个参数生成数据进行了全面的测试;编码规范,C++代码风格良好 | 可能不太会配工具链。 |
杜品豪 | 熟悉FlutterGUI开发;能够指出代码中一些潜在的性能问题;比较喜欢研究一些新的框架或库。 | 不太擅长算法。 |
界面模块,测试模块和核心模块的松耦合
合作小组:
姓名 | 学号 |
---|---|
徐凯 | 20373398 |
高一铭 | 20373194 |
使用上述合作小组的核心模块和本组的CLI,GUI进行了组合(因为每实现一部分就写了针对局部的单元测试,测试模块和我们的计算模块耦合太深,而且错误处理方式,异常定义也不同,难以测试)。(他们组好像最终没做和我们的计算模块的对接)。
CLI
add_executable(
cli
src/main.cpp
src/OptionParser.cpp
src/OptionParser.h
src/exceptions/WordListException.h
src/exceptions/WordListException.cpp)
link_directories(${CMAKE_CURRENT_SOURCE_DIR}/lib)
target_link_directories(cli PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/lib)
target_link_libraries(cli core_dll)
在试图链接合作小组发来的库的时候遇到了链接的问题,按理说link_directories
和target_link_directories
写一个就行,最后得都加上才能成功链接,这可能是我不太熟悉cmake
。
上面的聊天记录也提到了内存分配的问题,和我们的方式大致相同,所以没有遇到太多困难。
可以看到这条对出现环的报错信息来自他们的core。
我们的长这样
if (graph.has_cycle()) {
ERR(APIException, "word list has cycle without specify `enable_loop(-r)`.");
#ifndef USE_EXCEPTION
return 0;
#endif
CLI程序会打印exception的what方法返回的信息。
catch (std::exception const &e) {
std::cerr << "error: " << e.what() << std::endl;
}
GUI
库不兼容
因为我们的GUI使用Flutter,而Flutter在Windows上默认只支持MSVC编译器,MinGW编译的库貌似不能和MSVC混用,导致了很多奇怪的报错。
错误处理
他们的错误处理方式和GUI并不兼容,他们直接把错误信息从标准输出输出,而GUI需要在界面显示,于是需要修改为我们的方式(复制错误内容到内存中一个位置然后置错误标志):
int gen_chains_word(char* words[], int len, char* result[], char head, char tail, char reject, bool enable_loop) {
int num = 0;
try {
num = solution(words, len, result, 1, head, tail, reject, enable_loop, true);
}
catch (CircleTypeException &e) {
// cout << e.print_exception();
strcpy_s(error_msg, 256,e.what());
error_flag = 1;
}
catch (ResultOverflowException &e) {
// cout << e.print_exception();
strcpy_s(error_msg, 256,e.what());
error_flag = 1;
}
catch (WordsOverflowException &e) {
// cout << e.print_exception();
strcpy_s(error_msg, 256,e.what());
error_flag = 1;
}
return num;
}
未初始化的全局变量
他们有些变量开在了全局,且没有在每次计算时初始化。这在CLI上当然没有问题,因为只执行一次,而GUI在加载了DLL后需要执行多次计算,表现为第二次输入命令就会报错。
error C3848
可能是MSVC在某些方面要求比较严格,也可能是语言标准问题,在下面的代码需要加上const
struct cmp_record {
bool operator() (record record1, record record2) const{
return record1.nodes < record2.nodes;
}
};
内存泄漏和失效的迭代器
在调试中发现不只是全局变量未初始化的问题,还检查到了内存泄漏问题(使用valgrind),但凡有个借用检查器。
在迭代中把容器中的元素删除了是常见的问题,但是这大概算逻辑错误了,难以修改。
我们对接的是他们组的一个较老的版本,由于之后他们不打算对接了,CLI和计算模块耦合度较高,也没有办法更新(而且也不知道有没有解决此问题)。
最终效果
在经历了一系列修改后(大概还保留了很多他们计算模块的bug,但减少了程序崩溃的概率),大概并不能正常使用,但是可以在GUI上显示他们核心模块的bug。
希望他们在最终提交的程序中修复了这些问题。
实际花费时间
PSP2.1 | Personal Software Process Stages | 预估耗时(分钟) | 实际耗时(分钟) |
---|---|---|---|
Planning | 计划 | 60 | 120 |
· Estimate | · 估计这个任务需要多少时间 | 60 | 120 |
Development | 开发 | 1680 | 3480 |
· Analysis | · 需求分析 (包括学习新技术) | 180 | 600 |
· Design Spec | · 生成设计文档 | 120 | 120 |
· Design Review | · 设计复审 (和同事审核设计文档) | 60 | 60 |
· Coding Standard | · 代码规范 (为目前的开发制定合适的规范) | 60 | 60 |
· Design | · 具体设计 | 180 | 180 |
· Coding | · 具体编码 | 600 | 1800 |
· Code Review | · 代码复审 | 180 | 360 |
· Test | · 测试(自我测试,修改代码,提交修改) | 300 | 300 |
Reporting | 报告 | 540 | 360 |
· Test Report | · 测试报告 | 180 | 180 |
· Size Measurement | · 计算工作量 | 60 | 60 |
· Postmortem & Process Improvement Plan | · 事后总结, 并提出过程改进计划 | 300 | 120 |
合计 | 2280 | 3960 |