Week1Day3A:重构与测试【2023 安全创客实践训练|笔记】

内容为武汉大学国家网络安全学院2022级大一第三学期“996”实训课程中所做的笔记,仅供个人复习使用,如有侵权请联系本人,将与15个工作日内将博客设置为仅粉丝可见。

目录

避免过长的子程序

合并重复代码

让 if 语句更短

不要使用全局变量

破坏局域性

命名空间污染

代码重构总结

软件测试与测试的价值

软件测试

理解单元测试

白盒测试

代码覆盖分析

黑盒测试

理解黑盒测试

黑盒测试实践

作答方法

评分规则

评分策略

我的答案

格式化路径

输入格式

输出格式

样例输入

样例输出

任务提示

测试用例

我的答案

内存泄漏与检测方法


避免过长的子程序

蒜头君正在解一道编程题,题目是这样的:

首先定义“特殊日”,一个日期是“特殊日”当且仅当这个日期的年、月、日相加之和为质数。程序每次会输入一行字符串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;
}

接下来帮助蒜头君,按照老师的要求对这份代码进行重构吧!

把读入操作都移动到一个没有返回值、有三个整型指针参数yearmonthdayinit函数里吧。

任务提示:在main函数之前定义一个没有返回值、有三个整型指针参数yearmonthday的函数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,没有返回值,有三个整型指针参数yearmonthday,和一个整数指针参数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中,传入三个整数指针参数yearmonthday;和一个整数指针参数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;
}

接下来,帮助蒜头君把代码改好吧!

首先我们发现,下面这段代码在addDaysdelDays两个函数中都出现了:

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

并将addDaysdelDays方法中对应的代码删掉。

现在在addDaysdelDays里补回来sum的定义,并将其都初始化为toSum(my_date)的结果。

任务提示:在addDaysdelDays的第一行都加上

int sum = toSum(my_date);

蒜头君感觉代码短了很多!他满怀希望地把代码交给了老师,但老师对他说,这份代码还有非常大的改进空间。

聪明的你想必已经发现了,在addDaysdelDays函数内,仍然有大段完全一样的代码逻辑。接下来,我们就来把这部分代码也合并成一个函数吧!

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

并将addDaysdelDays函数中对应的代码删掉。

现在,再在addDaysdelDays函数的最后调用一下initFromSum函数吧。

任务提示:在addDaysdelDays函数的最后写上

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函数内最一开始写上:如果yeardate.year不相等,则直接返回year < date.year的结果。

任务提示

before方法内的一开始写上

if (year != date.year) {
    return year < date.year;
}

和上一步类似,我们把monthday的对应代码都写上吧。

需要思考下,如何将之前和判断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):测试用例是否能让程序中的每个判定至少取值为truefalse各一次。
  • 条件覆盖(condition coverage):测试用例是否能让程序中每个判定的每个条件都至少取值为truefalse各一次。

比如,对于代码if a < 5 and b > 10,那么如下的用例满足分支覆盖,但不满足条件覆盖:

aba<5b>10a < 5 and b > 10
311truetruetrue
00truefalsefalse

而如下的用例,满足条件覆盖却不满足分支覆盖:

aba<5b>10a < 5 and b > 10
32truefalsefalse
811falsetruefalse

为此,又有了要求更为苛刻的如下两种覆盖准则:

  • 条件/分支覆盖(condition/decision coverage):同时满足分支覆盖和条件覆盖。
  • 组合条件覆盖(multiple condition coverage):测试用例使得每种条件取值组合都被执行一次。

对于刚刚被测的代码if a < 5 and b > 10,如下的用例满足条件/分支覆盖:

aba<5b>10a < 5 and b > 10
311truetruetrue
82falsefalsefalse

而组合条件覆盖就更为严苛了,因为一共有两组条件,因此一共需要 2^2 组测试用例才能满足要求:

aba<5b>10a < 5 and b > 10
32truefalsefalse
811falsetruefalse
311truetruetrue
82falsefalsefalse

对于我们刚刚学习的这五种覆盖准则,可以通过下图来快速理解它们覆盖程度的不同:

在上图中,自上而下的覆盖程度由高到低,并且箭头的含义为: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两个解

根据如上的表格,我们就可以设计出一组合理的测试用例:

abc
000
001
015
111
1−21
−121

一个最经典的黑盒测试例子就是在线题库的判题功能,它实际上是对用户提交的代码进行自动化的黑盒测试。而编写题目的测试数据,实际上就是在设计黑盒的测试用例。


理解黑盒测试


黑盒测试实践

为检测三角形类型的函数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 操作后,结果如下:

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值