内容为武汉大学国家网络安全学院2022级大一第三学期“996”实训课程中所做的笔记,仅供个人复习使用,如有侵权请联系本人,将与15个工作日内将博客设置为仅粉丝可见。
目录
避免过长的子程序
蒜头君正在解一道编程题,题目是这样的:
首先定义“特殊日”,一个日期是“特殊日”当且仅当这个日期的年、月、日相加之和为质数。程序每次会输入一行字符串
yyyy-mm-dd
,表示一个起始日期的年、月、日,要从这天开始向后找一个最早的“特殊日”,如果这个“特殊日”距离起始日期不超过 100100 天则输出yyyy-mm-dd is a prime day.
,其中yyyy-mm-dd
是这个特殊日的年月日;否则输出There is no prime day.
。
经过不断的思考和编码,蒜头君终于解出了这道题,但是老师告诉他,这份代码虽然结果正确,但是不够清晰,因为蒜头君将初始化、天数累加和枚举、判断是否为质数以及输出结果全部都写在main
函数里了,一共有 40 多行,比较难阅读。
#include <stdio.h>
int main() {
int year, month, day;
int days[13] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
scanf("%d-", &year);
scanf("%d-", &month);
scanf("%d", &day);
int i,has_prime = 0;
for (i = 0; i < 100; ++i) {
int is_leap_year = 0;
if ((year % 100 != 0 && year % 4 == 0) || year % 400 == 0) {
is_leap_year = 1;
}
if (is_leap_year) {
days[2] = 29;
}
int numbers_sum = year + month + day;
int is_prime = 1;
for (i = 2; i < numbers_sum; ++i) {
if (numbers_sum % i == 0) {
is_prime = 0;
break;
}
}
if (is_prime) {
has_prime = 1;
break;
}
day++;
if (day > days[month]) {
month++;
day = 1;
}
if (month > 12) {
month = 1;
year++;
}
}
if (has_prime) {
printf("%04d-%02d-%02d is a prime day.\n", year, month, day);
} else {
printf("There is no prime day.\n");
}
return 0;
}
接下来帮助蒜头君,按照老师的要求对这份代码进行重构吧!
把读入操作都移动到一个没有返回值、有三个整型指针参数year
、month
、day
的init
函数里吧。
任务提示:在main
函数之前定义一个没有返回值、有三个整型指针参数year
、month
、day
的函数init
,并将main
函数里的如下三行移动到该函数内。
scanf("%d-", year);
scanf("%d-", month);
scanf("%d", day);
现在,在main
函数中调用我们刚刚定义的初始化函数吧。
任务提示:在main
函数has_prime
变量声明之前写上
init(&year, &month, &day);
接下来,我们把判断闰年的逻辑抽离出来,将main
函数中判断闰年的逻辑移动到一个返回值为int
、参数为一个整数year
的函数leapYear
中吧!你可能会觉得这步操作很繁琐,有些手足无措?不用慌,将main
函数中的判断闰年的如下代码
int is_leap_year = 0;
if ((year % 100 != 0 && year % 4 == 0) || year % 400 == 0) {
is_leap_year = 1;
}
剪切到新的函数中以后,再将is_leap_year
作为函数的返回值直接返回就可以了。
任务提示:
在main
函数上面写下
int leapYear(int year) {
int is_leap_year = 0;
if ((year % 100 != 0 && year % 4 == 0) || year % 400 == 0) {
is_leap_year = 1;
}
return is_leap_year;
}
并将main
函数中
int is_leap_year = 0;
if ((year % 100 != 0 && year % 4 == 0) || year % 400 == 0) {
is_leap_year = 1;
}
这段代码删除。
现在程序是无法编译的哦,在main
函数中加上leapYear
函数的正确的调用代码吧。
任务提示:将main
函数中的is_leap_year
改为leapYear(year)
。
下一个要抽离出来的逻辑是质数判断。定义一个返回值为int
,参数为一个整数numbers_sum
的函数prime
,并将main
函数中判断质数的逻辑移动到prime
函数中。
和leapYear
类似,我们在prime
函数里将is_prime
作为函数返回值就可以了。
任务提示:在main
函数前写上
int prime(int numbers_sum) {
int is_prime = 1;
for (int i = 2; i < numbers_sum; ++i) {
if (numbers_sum % i == 0) {
is_prime = 0;
break;
}
}
return is_prime;
}
并将main
函数中的以下代码删除。
int is_prime = 1;
for (i = 2; i < numbers_sum; ++i) {
if (numbers_sum % i == 0) {
is_prime = 0;
break;
}
}
现在程序又一次无法编译通过啦!在刚刚删除代码的位置之后,将if
中的代码改为对prime
函数的调用吧。
任务提示:将if (is_prime)
改为if (prime(numbers_sum))
。
我们看到,在每次循环枚举的最后,都会对日期进行累加。接下来,我们就要把它从main
函数中抽离出来。
定义一个函数add
,没有返回值,有三个整型指针参数year
、month
、day
,和一个整数指针参数days
。并把main
函数中对日期的累加操作移动到函数中。
任务提示
在main
函数前写上
void add(int* year, int* month, int* day, int* days) {
(*day)++;
if (*day > days[*month]) {
(*month)++;
*day = 1;
}
if (*month > 12) {
*month = 1;
(*year)++;
}
}
并将main
函数中的以下代码删除:
day++;
if (day > days[month]) {
month++;
day = 1;
}
if (month > 12) {
month = 1;
year++;
}
现在,在for
循环内加上日期累加函数的调用吧。
任务提示:在for
循环内的最后写上
add(&year, &month, &day, days);
经过不断地修改,我们发现,main
函数虽然已经比刚才精简不少,但还是太过复杂。现在,我们将查找“下一个特殊日”的过程抽离出来,移动 到一个返回值为int
的函数getNextDay
中,传入三个整数指针参数year
、month
和day
;和一个整数指针参数days
。当找到合法的下一个特殊日时,返回1
,否则返回0
。
可以将函数中getNextDay
的值作为has_prime
的返回值哦。
任务提示:在main
函数前写上如下的函数:
int getNextDay(int *year, int *month, int *day, int* days) {
int i, has_prime = 0;
for (i = 0; i < 100; ++i) {
if (leapYear(*year)) {
days[2] = 29;
}
int numbers_sum = *year + *month + *day;
if (prime(numbers_sum)) {
has_prime = 1;
break;
}
add(year, month, day, days);
}
return has_prime;
}
并将main
函数中int has_prime = 0;
及之后的for
循环删掉。
现在,在main
函数中,用调用getNextDay
函数的结果来替换if
中的条件。
任务提示:将if (has_prime)
改为if (getNextDay(&year, &month, &day, days))
。
#include <stdio.h>
void init(int *year, int *month, int *day) {
scanf("%d-", year);
scanf("%d-", month);
scanf("%d", day);
}
int leapYear(int year) {
int is_leap_year = 0;
if ((year % 100 != 0 && year % 4 == 0) || year % 400 == 0) {
is_leap_year = 1;
}
return is_leap_year;
}
int prime(int numbers_sum) {
int is_prime = 1;
for (int i = 2; i < numbers_sum; ++i) {
if (numbers_sum % i == 0) {
is_prime = 0;
break;
}
}
return is_prime;
}
void add(int *year, int *month, int *day, int *days) {
(*day)++;
if (*day > days[*month]) {
(*month)++;
*day = 1;
}
if (*month > 12) {
*month = 1;
(*year)++;
}
}
int getNextDay(int *year, int *month, int *day, int *days) {
int i, has_prime = 0;
for (i = 0; i < 100; ++i) {
if (leapYear(*year)) {
days[2] = 29;
}
int numbers_sum = *year + *month + *day;
if (prime(numbers_sum)) {
has_prime = 1;
break;
}
add(year, month, day, days);
}
return has_prime;
}
int main() {
int year, month, day;
int days[13] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
init(&year, &month, &day);
if (getNextDay(&year, &month, &day, days)) {
printf("%04d-%02d-%02d is a prime day.\n", year, month, day);
} else {
printf("There is no prime day.\n");
}
return 0;
}
这样,我们就完成了本章的第一个重构工作——将过长的子程序重构成较小的很多个子程序。有没有觉得代码重构以后变得更容易阅读了呢?
当然,除此之外,我们还有很多代码重构的方法,这些重构方法能够帮助我们将已有代码改为更易读、更好维护的代码。在后面的课程里,我们会一一学习这些方法。
合并重复代码
这天,蒜头君又写了一个小程序。程序输入一行三个整数,分别表示日期的年、月、日。接下来,程序输入一个整数delta1
,将日期加上delta1
天并按照y-m-d
的格式输出。之后程序再输入一个整数delta2
,将刚才算完的日期减去delta2
天并按照y-m-d
的格式输出。
蒜头君又自信满满地将代码交给了老师。老师说:“蒜头啊,你的代码里有很多重复的代码,如果将来调整逻辑,这两块全部都要修改,很容易改漏的。这份代码你再改改吧。”
#include <stdio.h>
#include <stdlib.h>
struct MyDate{
int year, month, day;
};
int days[13] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
int isLeap(int year) {
return year % 400 == 0 || (year % 4 == 0 && year % 100 != 0);
}
void addDays(struct MyDate *my_date, int delta) {
// convert date to the count of days
int i,sum = 0;
for (i = 1; i < my_date->year; ++i) {
if (isLeap(i)) {
sum++;
}
sum += 365;
}
for (i = 1; i < my_date->month; ++i) {
sum += days[i];
}
sum += my_date->day;
// add delta to the count of days
sum = sum + delta;
// convert the count of days to date
my_date->year = 1;
int now;
for (now = 365; ; ++my_date->year) {
if (isLeap(my_date->year)) {
now = 366;
} else {
now = 365;
}
if (sum <= now) {
break;
}
sum -= now;
}
my_date->month = 1;
for (;; ++my_date->month) {
if (sum <= days[my_date->month]) {
break;
}
sum -= days[my_date->month];
}
my_date->day = sum;
}
void delDays(struct MyDate *my_date, int delta) {
// convert date to the count of days
int i,sum = 0;
for (i = 1; i < my_date->year; ++i) {
if (isLeap(i)) {
sum++;
}
sum += 365;
}
for (i = 1; i < my_date->month; ++i) {
sum += days[i];
}
sum += my_date->day;
// add delta to the count of days
sum = sum - delta;
// convert the count of days to date
my_date->year = 1;
int now;
for (now = 365; ; ++my_date->year) {
if (isLeap(my_date->year)) {
now = 366;
} else {
now = 365;
}
if (sum <= now) {
break;
}
sum -= now;
}
my_date->month = 1;
for (;; ++my_date->month) {
if (sum <= days[my_date->month]) {
break;
}
sum -= days[my_date->month];
}
my_date->day = sum;
}
void print(struct MyDate my_date){
printf("%d - %d - %d\n", my_date.year, my_date.month, my_date.day);
}
int main() {
int year, month, day, delta;
scanf("%d %d %d",&year, &month, &day);
struct MyDate my_date = {year, month, day};
scanf("%d", &delta);
addDays(&my_date, delta);
print(my_date);
scanf("%d", &delta);
delDays(&my_date, delta);
print(my_date);
return 0;
}
接下来,帮助蒜头君把代码改好吧!
首先我们发现,下面这段代码在addDays
和delDays
两个函数中都出现了:
int i,sum = 0;
for (i = 1; i < my_date->year; ++i) {
if (isLeap(i)) {
sum++;
}
sum += 365;
}
for (i = 1; i < my_date->month; ++i) {
sum += days[i];
}
sum += my_date->day;
我们定义一个函数int toSum(struct MyDate my_date)
,返回值为当前日期对应的天数总和。之后把这两段相同的代码移动到函数里,并将sum
作为返回值。
任务提示:在isLeap
函数后定义一个函数:
int toSum(struct MyDate *my_date){
int i,sum = 0;
for (i = 1; i < my_date->year; ++i) {
if (isLeap(i)) {
sum++;
}
sum += 365;
}
for (i = 1; i < my_date->month; ++i) {
sum += days[i];
}
sum += my_date->day;
return sum;
}
并将addDays
和delDays
方法中对应的代码删掉。
现在在addDays
和delDays
里补回来sum
的定义,并将其都初始化为toSum(my_date)
的结果。
任务提示:在addDays
和delDays
的第一行都加上
int sum = toSum(my_date);
蒜头君感觉代码短了很多!他满怀希望地把代码交给了老师,但老师对他说,这份代码还有非常大的改进空间。
聪明的你想必已经发现了,在addDays
和delDays
函数内,仍然有大段完全一样的代码逻辑。接下来,我们就来把这部分代码也合并成一个函数吧!
my_date->year = 1;
int now;
for (now = 365; ; ++my_date->year) {
if (isLeap(my_date->year)) {
now = 366;
} else {
now = 365;
}
if (sum <= now) {
break;
}
sum -= now;
}
my_date->month = 1;
for (;; ++my_date->month) {
if (sum <= days[my_date->month]) {
break;
}
sum -= days[my_date->month];
}
my_date->day = sum;
现在定义一个函数void initFromSum(struct MyDate *my_date, int sum)
,并将这两段重复代码移动到这个函数里。
任务提示:在toSum
函数之后写上initFromSum
的定义:
void initFromSum(struct MyDate *my_date, int sum) {
my_date->year = 1;
int now;
for (now = 365; ; ++my_date->year) {
if (isLeap(my_date->year)) {
now = 366;
} else {
now = 365;
}
if (sum <= now) {
break;
}
sum -= now;
}
my_date->month = 1;
for (;; ++my_date->month) {
if (sum <= days[my_date->month]) {
break;
}
sum -= days[my_date->month];
}
my_date->day = sum;
}
并将addDays
和delDays
函数中对应的代码删掉。
现在,再在addDays
和delDays
函数的最后调用一下initFromSum
函数吧。
任务提示:在addDays
和delDays
函数的最后写上
initFromSum(my_date, sum);
蒜头君心想,这次代码应该没问题啦!把代码交给老师以后,老师对蒜头君说:“蒜头,现在意识到合并重复代码的意义了吧。在实际工程中,通过合并重复代码,使得代码量大大降低,可以明显提高代码的可维护性,尤其是在重复代码部分的逻辑需要调整时,可以避免出现忘记修改其中某处导致程序异常的情况哦。”
接下来,跟随我们的课程,继续学习更多重构方法吧!
#include <stdio.h>
#include <stdlib.h>
struct MyDate{
int year, month, day;
};
int days[13] = {0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
int isLeap(int year) {
return year % 400 == 0 || (year % 4 == 0 && year % 100 != 0);
}
int toSum(struct MyDate *my_date) {
int i,sum = 0;
for (i = 1; i < my_date->year; ++i){
if (isLeap(i)){
sum++;
}
sum += 365;
}
for (i = 1; i < my_date->month; ++i) {
sum += days[i];
}
sum += my_date->day;
return sum;
}
void initFromSum(struct MyDate *my_date, int sum) {
my_date->year = 1;
int now;
for (now = 365; ; ++my_date->year) {
if (isLeap(my_date->year)) {
now = 366;
} else {
now = 365;
}
if (sum <= now) {
break;
}
sum -= now;
}
my_date->month = 1;
for (;; ++my_date->month) {
if (sum <= days[my_date->month]) {
break;
}
sum -= days[my_date->month];
}
my_date->day = sum;
}
void addDays(struct MyDate *my_date, int delta) {
// convert date to the count of days
int sum = toSum(my_date);
// add delta to the count of days
sum = sum + delta;
// convert the count of days to date
initFromSum(my_date, sum);
}
void delDays(struct MyDate *my_date, int delta) {
// convert date to the count of days
int sum = toSum(my_date);
// add delta to the count of days
sum = sum - delta;
// convert the count of days to date
initFromSum(my_date, sum);
}
void print(struct MyDate my_date){
printf("%d - %d - %d\n", my_date.year, my_date.month, my_date.day);
}
int main() {
int year, month, day, delta;
scanf("%d %d %d",&year, &month, &day);
struct MyDate my_date = {year, month, day};
scanf("%d", &delta);
addDays(&my_date, delta);
print(my_date);
scanf("%d", &delta);
delDays(&my_date, delta);
print(my_date);
return 0;
}
让 if 语句更短
这次,蒜头君写了一个计算旅行团去某景点总票价的小程序,输入四个整数,前三个表示当天的日期,最后一个表示旅行团的游客总数。
景区给旅行团总票价的计算公式如下(只适用于 2016 年。总人数为 count,春季票价为 price_spring,淡季票价为 price_normal,折扣优惠为 price_discount):
-
春季(2016.3.1 - 2016.5.31):price_spring×count−price_discount×max(0,count−25)
-
淡季(2016 年其余时间):price_normal×count−price_discount×max(0,count−10)
蒜头把程序写出来了,交给老师。老师对他说:“蒜头啊,你程序运行的结果是正确的,但是还不够好。你有没有觉得你写的if
语句以及一些表达式都有些复杂、难以阅读?”
#include <stdio.h>
struct MyDate {
int year, month, day;
};
int before(struct MyDate date1, struct MyDate date2) {
if (date1.year == date2.year) {
if (date1.month == date2.month) {
if (date1.day == date2.day) {
return 0;
} else {
return date1.day < date2.day;
}
} else {
return date1.month < date2.month;
}
} else {
return date1.year < date2.year;
}
}
struct MyDate SPRING_START = {2016, 3, 1};
struct MyDate SPRING_END = {2016, 6, 1};
const int SPRING_PRICE = 200;
const int NORMAL_PRICE = 100;
const int DISCOUNT_PRICE = 20;
int max(int x, int y){
return x>y?x:y;
}
int main() {
int year, month, day, count;
scanf("%d %d %d %d",&year, &month, &day, &count);
struct MyDate date = {year, month, day};
if (before(SPRING_START, date) && before(date, SPRING_END)) {
printf("%d\n", SPRING_PRICE * count - DISCOUNT_PRICE * max(0, count - 25));
} else {
printf("%d\n", NORMAL_PRICE * count - DISCOUNT_PRICE * max(0, count - 10));
}
return 0;
}
我们继续帮蒜头来重构代码吧。
首先,if (before(SPRING_START, date) && before(date, SPRING_END))
中的表达式过于冗长了,我们在main
函数前定义一个函数int in_spring(struct MyDate date)
来实现这个功能吧。
任务提示:在main
函数前写上
int in_spring(struct MyDate date) {
return before(SPRING_START, date) && before(date, SPRING_END);
}
将main
函数中的if
里的表达式改为用in_spring
函数来表达吧!
任务提示:将
before(SPRING_START, date) && before(date, SPRING_END)
改为
in_spring(date)
现在我们回去看MyDate
结构体的before
,有没有觉得蒜头君写的过于复杂了?我们想办法把这段代码改的更简洁吧!
我们发现,这段层层嵌套的if
语句虽然看起来非常复杂,但实际上可以简单地分为三层,每层形如:
if (xxx) {
// do something
} else {
return yyy;
}
如果我们将if-else
反过来呢?当我们写完
if (!xxx) {
return yyy;
}
之后,不用在写那层else
了,将之前if
中的代码放到现在if
代码块的后面和之前是完全等价的。
接下来,我们先把新的代码一步步写上,再删掉冗长的旧代码吧。
首先,在before
函数内最一开始写上:如果year
和date.year
不相等,则直接返回year < date.year
的结果。
任务提示
在before
方法内的一开始写上
if (year != date.year) {
return year < date.year;
}
和上一步类似,我们把month
和day
的对应代码都写上吧。
需要思考下,如何将之前和判断day
相关的代码精简到一行呢?
if (date1.day == date2.day) {
return 0;
} else {
return date1.day < date2.day;
}
任务提示:在before
内刚刚写下的代码后继续写上
if (date1.month != date2.month) {
return date1.month < date2.month;
}
return date1.day < date2.day;
现在,把before
那段冗长而又无用的代码删掉吧!
任务提示:将以下代码删除:
if (date1.year == date2.year) {
if (date1.month == date2.month) {
if (date1.day == date2.day) {
return 0;
} else {
return date1.day < date2.day;
}
} else {
return date1.month < date2.month;
}
} else {
return date1.year < date2.year;
}
至此,对if
语句的重构已经完成了!
#include <stdio.h>
struct MyDate {
int year, month, day;
};
int before(struct MyDate date1, struct MyDate date2) {
if (date1.year != date2.year)
{
return date1.year < date2.year;
}
if (date1.month != date2.month)
{
return date1.month < date2.month;
}
return date1.day < date2.day;
}
struct MyDate SPRING_START = {2016, 3, 1};
struct MyDate SPRING_END = {2016, 6, 1};
const int SPRING_PRICE = 200;
const int NORMAL_PRICE = 100;
const int DISCOUNT_PRICE = 20;
int max(int x, int y){
return x>y?x:y;
}
int in_spring(struct MyDate date)
{
return before(SPRING_START,date)&&before(date,SPRING_END);
}
int main() {
int year, month, day, count;
scanf("%d %d %d %d",&year, &month, &day, &count);
struct MyDate date = {year, month, day};
if (in_spring(date)) {
printf("%d\n", SPRING_PRICE * count - DISCOUNT_PRICE * max(0, count - 25));
} else {
printf("%d\n", NORMAL_PRICE * count - DISCOUNT_PRICE * max(0, count - 10));
}
return 0;
}
除了if
以外,这份代码还有很多可重构的地方,比如将SPRING_PRICE * count - DISCOUNT_PRICE * max(0, count - 25)
改为getSpringPrice(count)
可以明显提高代码的可读性。
不要使用全局变量
在我们刚刚开始编程时,往往会经常在代码中使用全局变量,因为它定义方便、可以在多个子程序中共享,使得我们无需在调用函数时多传一个参数。
但是,在进行软件开发时,时刻牢记,尽量避免使用全局变量。
使用全局变量都有什么坏处呢?
破坏局域性
当源代码的每一个模块都非常独立时,代码就会更容易被理解。当全局变量出现在多个子程序里,甚至在多个子程序中被修改时,就破坏了各个子程序的局域性,带来了 隐式耦合。这使得包含这个全局变量的子程序非常难以读懂,维护代码的工程师必须要阅读大量的代码才能厘清程序的数据流——有哪些子程序访问了全局变量的值,又有哪些子程序修改了全局变量的值。
命名空间污染
当你将一个变量放在全局而不是某一个命名空间下时,有一定几率会遇到全局变量冲突的情况。幸运的话,变量名冲突会导致链接失败等错误;如果非常不幸,编译器没有提示这个错误的话,各个模块可能会发生意想不到的事情。
#ifndef GLOBAL_VARIABLES
#define GLOBAL_VARIABLES
int global_variable = 0;
#endif
void run1() {
printf("%d\n", global_variable);
}
#ifndef GLOBAL_VARIABLES
#define GLOBAL_VARIABLES
int global_variable = 5;
#endif
void run2() {
printf("%d\n", global_variable);
}
上面这两份 C 代码,程序员的预期是第一份代码的run1
函数输出0
,而第二份代码的run2
函数输出5
。但实际上,最终两个函数的输出结果一定是一样的,而结果则取决于哪份代码先被执行。
如果在某些情况下不得不使用全局变量,可以将其放到特定的namespace
中,而不要让他们“暴露”在外面。
除此之外,还会因此带来测试不便、难于集成、容易造成并发问题等很多坏处,所以时刻牢记,尽量避免使用全局变量。
那么对于已经有大量全局变量的代码,要如何修改呢?对于大多数的全局变量而言,是可以想办法改成局部变量的;另外一些可以把它们改成 单例类(singleton class)。这样,我们就可以既保证数据的全局性,又能够控制这些全局信息的访问权限。
代码重构总结
软件测试与测试的价值
软件测试
在介绍软件测试之前,我们先来回顾一个你应该之前有所耳闻的名词——bug。
什么是 bug?bug 即 软件缺陷,是指存在于软件之中的,不希望或不可接受的故障。
至于为什么被叫做“bug”,实际上是一个非常有趣的故事。1947 年,哈佛大学计算实验室的一位教授在为 Mark II 检查问题的时候,在它的继电器中发现了一只飞蛾,将其贴在了日志上,并写下“Fist actual case of bug being found”(第一个被发现的 bug 案例)。
这个贴着飞蛾的日志至今仍保存在博物馆中。
现在进入正题。软件测试这个词想必你也都听说过了,这里再给你严谨地定义一下:软件测试是一种用来促进鉴定软件的正确性、完整性、安全性和质量的过程。说得直白一些,软件测试是发现错误、衡量软件质量和对是否满足要求进行评估的过程。
说了这么多,我们为什么要进行软件测试呢?当不测试的代价比测试更大时,我们就要进行软件测试。
举个简单的例子:当你在完成平时的编程作业时,往往是不需要进行测试,或者说,不需要进行严格的测试的——当你的程序出现错误后,只是被扣掉几分而已。然而当开发手术操控系统、载人飞船系统、甚至核弹控制系统的时候,一个非常微小的错误可能会导致性命攸关,甚至是地球毁灭。这种情况下,测试就显得非常重要了。
在这一章中,我们都将围绕软件测试进行学习,包括单元测试、白盒测试、黑盒测试、功能测试和非功能测试等内容。这一节中,我们先来学习一下单元测试的概念。
单元测试(unit test) 是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。单元测试往往由开发人员自己编写,软件开发人员修改一次代码通常就要进行至少一次单元测试,并且往往使用的开发语言或框架会提供现成的单元测试工具。
接下来我们来用 C++ 语言举个小例子。
下面这份代码是用 C++ 实现的计算斐波那契数列第 n 项结果的函数。
int fib(int x) {
if (x == 1) {
return 1;
} else if (x == 3) {
return 2;
} else {
return fib(x - 1) + fib(x - 2);
}
}
在 C++ 中,可以用 断言(assert) 来实现单元测试。假如上述代码保存在 fib.h 文件中,并写出如下的单元测试代码:
#include <cassert>
#include "fib.h"
int main() {
assert(fib(1) == 1);
assert(fib(2) == 1);
assert(fib(10) == 55);
return 0;
}
其中,fib(10) == 55
表示:当fib(10) == 55
返回true
,也就是fib(10)
的值为 5555 时,程序会继续执行后面的语句,否则会直接异常退出。
运行一下上面的两份代码,就能找到前面fib
函数的一处 bug 哦。
理解单元测试
白盒测试
刚刚我们完成了一次单元测试的练习。事实上,就像计算机中的 0/1 一样,在软件测试中,也有这样一对测试方法:白盒测试和黑盒测试。而单元测试,是白盒测试这一大类中的一种测试方法。
那么,究竟什么是白盒测试?
白盒测试(white-box testing) 是一种考虑系统或组件内部机制的测试方法,常被程序开发者用于对代码进行自测。比如我们前面提到的单元测试,就需要软件开发者自己设计单元测试代码,并执行测试。
除了单元测试外,白盒测试还包括代码审查、集成测试等常用的测试方法。
接下来,我们将要学习这些常用的白盒测试方法。
代码审查(code review) 是指对程序进行系统化地审查,往往通过同事或同行评审的方式进行。代码审查的目标是找出并修正代码中的错误及不符合规范的地方,提升软件质量和可维护性,减少之后发现错误的几率。
代码审查有很多种形式,包括结对编程、非正式的代码阅读和正式的代码审查。
代码审查不是简单地把代码拿给同事看,更不是一个流于形式的步骤。审查的同事要对代码中有疑问、不符合规范、效率低的地方对开发者进行询问,开发者必须对这些有时候看起来“吹毛求疵”的问题给出详细解答,并在之后对找出的问题进行修复。如此“审查---修复”不断往复,直到审查者通过为止。
一些新入行的程序员往往会很怕 code review,担心会因为代码的各种错误被同事或上司责备。切不可有如此的想法,只要这次被发现的错误以后努力避免,经过一段时间就会发现自己的能力有了质的提升。
集成测试(integration testing, I&T) 是一种将若干独立的软件模块组装起来进行测试的方法。
集成测试往往会在单元测试之后,测试的是多个模块之间的接口,包括模块之间的调用关系和同步控制、模块之间的通信、第三方中间件等。
在进行单元测试时,我们往往使用 代码覆盖(code coverage) 来确保源代码被测试完全。
根据预期覆盖程度的不同,有如下一些对应的覆盖准则:
- 子程序覆盖(function coverage):测试用例是否调用了程序的每一个子程序。
- 语句覆盖(statement coverage):测试用例是否执行了程序的每一条语句。
- 分支覆盖(branch coverage):测试用例是否能让程序中的每个判定至少取值为
true
、false
各一次。 - 条件覆盖(condition coverage):测试用例是否能让程序中每个判定的每个条件都至少取值为
true
、false
各一次。
比如,对于代码if a < 5 and b > 10
,那么如下的用例满足分支覆盖,但不满足条件覆盖:
a | b | a<5 | b>10 | a < 5 and b > 10 |
---|---|---|---|---|
3 | 11 | true | true | true |
0 | 0 | true | false | false |
而如下的用例,满足条件覆盖却不满足分支覆盖:
a | b | a<5 | b>10 | a < 5 and b > 10 |
---|---|---|---|---|
3 | 2 | true | false | false |
8 | 11 | false | true | false |
为此,又有了要求更为苛刻的如下两种覆盖准则:
- 条件/分支覆盖(condition/decision coverage):同时满足分支覆盖和条件覆盖。
- 组合条件覆盖(multiple condition coverage):测试用例使得每种条件取值组合都被执行一次。
对于刚刚被测的代码if a < 5 and b > 10
,如下的用例满足条件/分支覆盖:
a | b | a<5 | b>10 | a < 5 and b > 10 |
---|---|---|---|---|
3 | 11 | true | true | true |
8 | 2 | false | false | false |
而组合条件覆盖就更为严苛了,因为一共有两组条件,因此一共需要 2^2 组测试用例才能满足要求:
a | b | a<5 | b>10 | a < 5 and b > 10 |
---|---|---|---|---|
3 | 2 | true | false | false |
8 | 11 | false | true | false |
3 | 11 | true | true | true |
8 | 2 | false | false | false |
对于我们刚刚学习的这五种覆盖准则,可以通过下图来快速理解它们覆盖程度的不同:
在上图中,自上而下的覆盖程度由高到低,并且箭头的含义为:A 指向 B 意味着满足 A 就一定要满足 B,即 B 是 A 的必要条件。
当我们在利用代码覆盖准则进行单元测试时,往往按如下流程进行:
- 选择预期覆盖程度对应的覆盖准则;
- 选择测试路径以满足选定的测试准则;
- 根据测试路径设计测试用例的输入数据;
- 根据输入数据确定预期输出数据,完成单元测试的开发。
代码覆盖分析
对于如下代码:
int combination(int x, int y) {
if (x == 0 || y == 0) {
return 1;
}
return combination(x - 1, y) + combination(x - 1, y - 1);
}
int calc(int n) {
if (n == 1) {
return 1;
} else {
return combination(n, n / 2) + calc(n - 1);
}
}
设计有如下的单元测试:
assert(calc(10) == 1198);
请问这组单元测试满足哪些覆盖准则呢?
黑盒测试
黑盒测试(black-box testing) 是一种无需观察程序内部,只通过给定的输入和输出对程序进行测试的方法。
黑盒测试往往又被称为功能测试或基于文档的测试。和白盒测试不同的是,黑盒测试由测试人员而非开发人员执行,会把被测程序当成一个“黑盒子”,就像下面这张图。
相比于白盒测试,黑盒测试有很多优势:
- 测试人员无需了解程序内部代码实现
- 测试用例不依赖程序内部的设计
- 可以从用户的角度出发进行测试
当然,缺点也很明显,就是代码中很多隐藏的缺陷难以被发现,因为黑盒测试不能像白盒测试那样,让测试用例覆盖到所有分支和条件。
在进行黑盒测试时,最重要的就是 测试用例(test case) 的设计。还记得这节一开始的那张图么?测试用例指的就是用来验证“黑盒”正确性的输入、输出数据。
在设计测试用例时,往往将程序的输入/输出划分为若干段等价区间,并从每个区间中选取一个,一并作为测试用例集合。
例如,当我们测试一个计算方程 ax^2+bx+c=0 的解(当有任意多解时,抛出异常)的函数vector<double> quadratic(double a, double b, double c)
时,划分的区间如下:
输入 | 输出 |
---|---|
a=0,b=0,c=0 | 抛出异常 |
a=0,b=0,c≠0 | 无解 |
a=0,b≠0 | 一个解 |
a≠0,b^2−4ac<0 | 无解 |
a≠0,b^2−4ac=0 | 一个解 |
a≠0,b^2−4ac>0 | 两个解 |
根据如上的表格,我们就可以设计出一组合理的测试用例:
a | b | c |
---|---|---|
0 | 0 | 0 |
0 | 0 | 1 |
0 | 1 | 5 |
1 | 1 | 1 |
1 | −2 | 1 |
−1 | 2 | 1 |
一个最经典的黑盒测试例子就是在线题库的判题功能,它实际上是对用户提交的代码进行自动化的黑盒测试。而编写题目的测试数据,实际上就是在设计黑盒的测试用例。
理解黑盒测试
黑盒测试实践
为检测三角形类型的函数triangle_type
编写黑盒测试用例。该函数对不同的输入参数a, b, c
将会输出以下值:
- 等边三角形。
- 等腰(非等边)三角形。
- 普通三角形。
- 退化三角形(两边之和等于第三边)。
- 非三角形(三边之和小于第三边)。
此外,程序还将根据以下两种错误情况抛出异常:
- 零输入(一个或多个输入为 00)。
- 负输入(一个或多个输入为负数)。
作答方法
请在目录中data.csv
文件里输入你的测试用例。测试用例的格式为 CSV,且必须包含a,b,c
的表头,例如:
a,b,c
1,2,3
1,3,3
数据可以为整数或浮点数。
评分规则
对于前五组正常数据,每种情况被完全覆盖到可以获得 14 分;对于最后两组异常数据,每种情况被完全覆盖到可以获得 15 分。若某种情况没有考虑完全,则该情况对应的得分为 0。
评分策略
你可以按照你的需要进行任意多次的提交。你的最终分数将以最高一次提交得分为准。
run.py:
# coding: utf-8
import datetime
def triangle(side):
for s in side:
if s == 0:
return 5
if s < 0:
return 6
if side[0] == side[1] and side[0] == side[2]:
return 0
if side[0] + side[1] == side[2]:
return 3
if side[0] + side[1] < side[2]:
return 4
if side[0] == side[1] or side[1] == side[2]:
return 1
return 2
if __name__ == '__main__':
task = ['等边三角形\t', '等腰三角形\t', '普通三角形\t', '退化三角形\t', '非三角形\t', '零输入\t\t', '负输入\t\t']
flag = [0] * 7
score = 0
cnt = 0;
for line in open('data.csv'):
if cnt == 0:
cnt += 1
continue
side = line.strip('\n').split(',')
if len(side) != 3:
print 'Data format error!'
exit(1)
side = [float(i) for i in side]
side.sort()
flag[triangle(side)] = 1
for i in xrange(5):
score += 14 * flag[i]
for i in xrange(5, 7):
score += 15 * flag[i]
print datetime.datetime.now().strftime('%b-%d-%y %H:%M:%S')
for i in range(7):
print task[i],
if flag[i]:
print ': passed'
else:
print ': failed'
print 'Total Score'
print score
我的答案
a,b,c
3,3,3
3,3,4
3,4,5
1,2,3
1,2,4
0,1,2
-1,2,3
格式化路径
- 时间限制:1000ms
- 空间限制:131072K
- 语言限制:C语言
实现一个对输入的路径进行处理、并输出正确结果的程序。具体要求如下:
- 所有输入中的
\
被当做/
加以处理,且在输出中被转换为/
。 - 连续多个
/
被压缩成一个。 - 输入中单独的
.
被直接删除。若为连续的.
,则参考下面一条规则。 - 输入中的
..
会导致..
之前的一级目录被删去。然而如果输入中已经不存在父目录,则直接输出Value Error
。保证不会出现...
、....
等不合法的情况。 - 如果输入为空,或者所有父目录被删去,则根据下面的规则输出空串或
/
。- 当且仅当输入以
/
开始(或以\
开始)时,输出才以/
开始。 - 无论输入是否以
/
结尾,输出的结尾不带有/
,除非输出是/
。
- 当且仅当输入以
使用之前学到的白盒测试和黑盒测试方法,尽量减少错误次数。能一次通过最好啦!
输入格式
输入 T(1≤T≤10) 行,每行为一个长度不超过 100 的字符串(包含字母、数字、.
、\
和/
),表示输入的路径。
输出格式
输出 T 行字符串,表示处理后的 T 个路径。
样例输入
./jisuanke\\./suantou/../bin/
..
/\./\.\/.\/
样例输出
jisuanke/bin
Value Error
/
任务提示
这道题的细节很多,多去想一些边缘的测试用例,并自测通过,以确保你自己的程序是正确的。
测试用例
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main()
{
srand(time(NULL));
int t = rand() % 10 + 1;
for (int i = 0; i < t; i++)
{
int length = 0;
int dotnum = 0;
while (length < 99)
{
int opr = rand() % 8;
while (opr == 0 && dotnum == 1)
opr = rand() % 8;
char c;
switch (opr)
{
case 0:
dotnum++;
putchar('.');
break;
case 1:
putchar('/');
break;
case 2:
putchar('\\');
break;
case 3:
c = rand() % 26 + 'a';
putchar(c);
break;
case 4:
c = rand() % 26 + 'A';
putchar(c);
break;
case 5:
c = rand() % 10 + '0';
putchar(c);
break;
case 6:
putchar('\n');
break;
case 7:
puts("/../");
break;
}
if (opr == 6)
break;
else
length++;
}
if (length >= 99)
printf("\n");
}
return 0;
}
我的答案
#include <stdio.h>
#include <string.h>
#define MAX 1000
#define ISLETTER(x) ((x>='a'&&x<='z')||(x>='A'&&x<='Z'))
#define ISDIGIT(x) (x>='0'&&x<='9')
#define ISSLASH(x) (x=='/'||x=='\\')
void Format(char* s)
{
char* st[MAX];
int top = -1;
char* p = s;
while (*p != '\0')
{
if (ISLETTER(*p)||ISDIGIT(*p))
{
st[++top] = p;
while (ISLETTER(*p)||ISDIGIT(*p))
{
p++;
}
continue;
}
else if (*p == '.')
{
if (*(p+1) != '.')
{
p++;
continue;
}
if (top == -1)
{
strncpy(s, "Value Error",MAX);
return;
}
top--;
p += 2;
continue;
}
else
{
p++;
}
}
char tmp[MAX] = "";
char* t =tmp;
if (ISSLASH(s[0]))
{
*t++ = '/';
}
int front = 0;
while (front <= top)
{
char* p = st[front++];
while ((ISLETTER(*p)||ISDIGIT(*p)) && *p != '\0')
{
*t++ = *p++;
}
*t++ = (front <= top ? '/' : '\0');
}
strncpy(s, tmp, MAX);
return;
}
int main()
{
char s[MAX];
while (fgets(s, MAX, stdin))
{
s[strlen(s) - 1] = '\0';
Format(s);
puts(s);
}
return 0;
}
内存泄漏与检测方法
当我们使用 C 语言的 malloc 进行变量的内存动态分配后,务必记得回收内存,否则会导致内存泄漏。
比如下面这个程序:
#include <stdio.h>
#include <stdlib.h>
void f(void)
{
void* s;
s = malloc(50); /* 申请内存空间 */
return; /* 内在泄漏 - 参见以下资料 */
/*
* s 指向新分配的堆空间。
* 当此函数返回,离开局部变量s的作用域后将无法得知s的值,
* 分配的内存空间不能被释放。
*
* 如要「修复」这个问题,必须想办法释放分配的堆空间,
* 也可以用alloca(3)代替malloc(3)。
* (注意:alloca(3)既不是ANSI函数也不是POSIX函数)
*/
}
int main(void)
{
/* 该函数是一个死循环函数 */
while (true) f(); /* Malloc函数迟早会由于内存泄漏而返回NULL*/
return 0;
}
在函数 f()
中申请了内存却没有释放,导致内存泄漏。当程序不停地重复调用这个有问题的函数 f
,申请内存函数 malloc()
最后会在程序没有更多可用存储器可以申请时产生错误(函数输出为 NULL
)。但是,由于函数 malloc()
输出的结果没有加以出错处理,因此程序会不停地尝试申请存储器,并且在系统有新的空闲内存时,被该程序占用。注意,malloc()
返回 NULL
的原因不一定是因为前述的没有更多可用存储器可以申请,也可能是逻辑地址空间耗尽,在 Linux
环境上测试的时候后者更容易发生。
内存泄漏往往是由于 malloc 和 free 没有成对出现。当代码量较大时,内存泄漏问题往往难以通过肉眼检查,需要借助更为高效的工具完成对内存泄漏的检测。
目前最为流行的内存泄漏检测工具是 valgrind。
valgrind 包括如下一些工具:
- Memcheck:这是 valgrind 应用最广泛的工具,也是默认执行的工具,一个重量级的内存检查器,能够发现开发中绝大多数内存错误使用情况,比如:使用未初始化的内存,使用已经释放了的内存,内存访问越界等。这也是本文将重点介绍的部分。
- Callgrind:它主要用来检查程序中函数调用过程中出现的问题。
- Cachegrind:它主要用来检查程序中缓存使用出现的问题。
- Helgrind:它主要用来检查多线程程序中出现的竞争问题。
- Massif:它主要用来检查程序中堆栈使用中出现的问题。
- Extension:可以利用 core 提供的功能,自己编写特定的内存调试工具。
valgrind 参数列表
举例说明:
#include <stdlib.h>
#include <stdio.h>
int main() {
int size = 10;
char* buffer = (char*)malloc(sizeof(char) * size);
buffer[1] = 5;
return 0;
}
接下来执行
gcc -g -o g g.c
valgrind --leak-check=full ./g
结果如下:
结果中包含以下信息。
- HEAP SUMMARY,它表示程序在堆上分配内存的情况,1 allocs 表示分配了 1 次内存,0 frees 表示释放了 0 次,10 bytes allocated 表示分配了 10 个字节如果有泄漏,valgrind 会报告是哪个位置发生了泄漏(main 中 cpp 第 6 行)
- LEAK SUMMARY,表示不同的内存丢失类型
- definitely loss: 确认丢失,需修复因为在程序运行完的时候,没有指针指向它,指向它的指针在程序中丢失了;
- indirectly lost: 间接丢失,无须处理,当使用了含有指针成员的类或结构时可能会报这个错误。这类错误无需直接修复,他们总是与"definitely lost"一起出现,只要修复"definitely lost"即可;
- possibly lost: 可能丢失,需修复,发现了一个指向某块内存中部的指针,而不是指向内存块头部。这种指针一般是原先指向内存块头部,后来移动到了内存块的中部,还有可能该指针和该内存根本就没有关系,检测工具只是怀疑有内存泄漏。
- still reachable: 可以访问,需修复,未丢失但也未释放。如果程序是正常结束的,那么它可能不会造成程序崩溃。表示泄漏的内存在程序运行完的时候,仍旧有指针指向它,因而,这种内存在程序运行结束之前可以释放。一般情况下 valgrind 不会报这种泄漏,除非使用了参数 --show-reachable=yes。
- suppressed:已被解决,无须处理,出现了内存泄露但系统自动处理了;可以无视这类错误。
当我们加入对应的 free 操作后,结果如下: