北京大学程序设计MOOC作业详解-02-类和对象基础
第六题继续:
题目内容详情请见:类和对象基础(上)
为了完成本题目,我们在编程实践时遇到了一个困难:输入数据格式问题。这是因为单纯用cin或者scanf/sscanf会遇到麻烦,分析如下:
首先,明确输入的内容及格式,样例如下:
Tom Hanks,18,7817,80,80,90,70
很明显,姓名的姓氏和名字用空格隔开,年龄、学号及四年成绩用逗号隔开,这是麻烦的根源,下面分析几种不同的输入方式。
第一种,cin输入
void input() {
string name1, name2;
cin >> name1 >> name2 >> age >> stuId >> sc1 >> sc2 >> sc3 >> sc4;
name = name1 + " " + name2;
}
cin是一种遇到空格会停止读取的输入方式,这意味着,实际读入了两个字符串,即:
Tom
Hanks,18,7817,80,80,90,70
这显然不对,会给“年龄,学号,成绩”等成员变量赋予一个随机的值。
第二种,scanf格式输入
这里直接插入scanf的输入代码,根据格式要求,理应输入:
scanf("%s %s %d,%s,%d,%d,%d,%d",
name1, name2, &age, stuId, &sc1, &sc2, &sc3, &sc4);
但是,scanf和cin一样,要么完全按照空格分割输入内容,要么没有空格完全按照格式输入内容。
因为:如果既有空格又有逗号,程序无法区别,第二个串Hanks,18,7817,80,80,90,70
到底是name2还是其他内容的格式输入信息。这种歧义会让程序认为它是name2。
第三种,stringstream流完成字符串分割
void input() {
string inp, *temp = new string[7]();
cin >> name >> inp;
stringstream ss(inp);
int idx = 0;
while (getline(ss, temp[idx], ',')) {
++idx;
}
if (idx == 7) {
name += (" " + temp[0]);
age = stoi(temp[1]);
stuId = temp[2];
sc1 = stoi(temp[3]);
sc2 = stoi(temp[4]);
sc3 = stoi(temp[5]);
sc4 = stoi(temp[6]);
}
delete[] temp;
}
总结stringstream在分割字符串中的用法,总体来说,分为两步:
stringstream ss(str_line);// 所有输入字符串,初始化流
vector<string> tokens;
string cur_str;
while (getline(ss, cur_str, ',') {
tokens.emplace_back(move(cur_str));// 减少拷贝
/*
上述代码使用移动语义减少拷贝,功能上等价于:
tokens.push_back(cur_str);
*/
}
但是,在这个题中有一个重大BUG,姓名并非一定有空格隔开姓氏与名字,也可以只有一个名字,即:
Tom Hanks
Vogon
均是可以输入的姓名,一旦是后者(Vogon),那就不存在空格,在这行代码会出现BUG:cin >> name >> inp;
,因为这里默认有姓氏和名字了。应该这样修改:
void input() {
string inpInfo, cur_info;
vector<string> temp;
getline(cin, inpInfo);
stringstream ss(inpInfo);
while (getline(ss, cur_info, ',')) {
temp.emplace_back(move(cur_info));
}
if (temp.size() == 7) {
name = temp[0];
age = stoi(temp[1]);
stuId = temp[2];
sc1 = stoi(temp[3]);
sc2 = stoi(temp[4]);
sc3 = stoi(temp[5]);
sc4 = stoi(temp[6]);
}
}
值得注意的是,会有一个警告报出:“使用已移动的from对象”,这是从代码块:
while (getline(ss, cur_info, ',')) {
temp.emplace_back(move(cur_info));
}
报出来的,这是因为移动语义是将cur_info的所有权移交给temp.back()了,即向量temp的尾部指针/对象。这个警告需要管吗?在标准库下不用管。可以参考《C++ Primier》的一句话:
也就是说,被移动的对象的状态,由上下文决定。也就是说,只要cur_info在合法作用域内,那移动后的cur_info仍然是合法的状态,即可以赋值。
第四种,strtok函数及应用
第三种,用stringstream字符串流分割字符串,已经可以满足需求了。即使题目并不支持vector库,但是也可以改用string数组完成,这里不再赘述。
本节,给大家介绍strtok函数的应用,以及其线程安全与源码分析。
strtok函数在本题的解法
char *strtok(char *str, const char *delim)
这是strtok的函数声明,其中str是待分割的字符串,delim是分隔符,以该字符串为分割依据进行分割,然后返回当前分割的结果。
本题运用strtok的AC代码如下:
private:
string name;
string stuId;
int age, sc[4];
double score;
public:
void input() {
char infoBuf[128] = { '\0' };
cin.getline(infoBuf, 128);
int idx = 0;
char* cur_str = strtok(infoBuf, ",");
while (cur_str) {
switch (idx)
{
case 0:
name = cur_str;
break;
case 1:
age = atoi(cur_str);
break;
case 2:
stuId = cur_str;
break;
case 3:
case 4:
case 5:
case 6:
sc[idx - 3] = atoi(cur_str);
break;
default:
break;
}
cur_str = strtok(nullptr, ",");
++idx;
}
}
void calculate() {
score = (sc[0] + sc[1] + sc[2] + sc[3]) / 4.0;
}
void output() {
cout << name << "," << age << "," << stuId << "," << score << endl;
}
strtok函数的应用
从上面的例子可以总结,使用strtok函数分为两步:
// 第一步:分割字符串,并将剩余字符串转存到一个static变量中
char* cur_str = strtok(src_str, delim);
while (cur_str) {
cout << cur_str << endl;
// 第二步:无需转存,所以传入nullptr,只需在static变量中分割子串
cur_str = strtok(nullptr, delim);
}
需要注意的是,由于是用静态变量存储剩余的字符串,在多线程下就不安全了。这是因为:一个线程分割了字符串,另一个线程就得不到原始的字符串了,而是分割后剩下的子串。参考网上的资料可知:
使用该函数进行字符串分割时,会破坏被分解字符串的完整,调用前和调用后的s已经不一样了。第一次分割之后,原字符串str是分割完成之后的第一个字符串,剩余的字符串存储在一个静态变量中。
这也是为什么,后续不用再传递str给函数的原因,因为str并不包含剩余子串的任何信息了。
做一道简单的应用题:解析一个字符串,并存储到结构体中
【输入】输入一个任意长的字符串,按照如下格式:
Aob male 18,Bob male 19,Cob female 20
【输出】输出json的格式化信息,例如:
{
{
name: Aob,
sex: male,
age: 18
},
{
name: Bob,
sex: male,
age: 19
},
{
name: Cob,
sex: female,
age: 20
}
}
这个题有一个困难:第一次解析,得到第一个人的信息后,剩余子串存储在全局静态的变量当中。然后第二次解析,解析学生个人的信息时,又改变了静态变量,导致丢失了剩余子串。
为了避免这个静态变量,我们需要使用线程安全的strtok,即Linux下用strtok_r,Windows下用strtok_s。
一个BUG:使用strtok_r/strtok_s时,输入的被分割字符串str不能是字符串常量,否则会段错误!
用法示例如下:
char* ctx = nullptr;
char* cur_str = strtok_s(buf, ",", &ctx);
while (cur_str) {
/*
再声明一个上下文,用来存储子串的分割剩余串
*/
cur_str = strtok_s(nullptr, ",", &ctx);
}
本练习题的代码如下:
#define _CRT_SECURE_NO_WARNINGS
#include <cstring>
#include <vector>
#include <string>
#include <iostream>
using namespace std;
struct Student {
string m_name;
string m_sex;
uint32_t m_age;
Student(char* buf) {
readStudent(buf);
}
void jsonPrinter() const {
cout << "\t{\n";
cout << "\t\tname: " << m_name << endl;
cout << "\t\tsex : " << m_sex << endl;
cout << "\t\tage : " << m_age << endl;
cout << "\t}\n";
}
void readStudent(char* buf) {
char* ctx_info = nullptr;
char* cur_info = strtok_s(buf, " ", &ctx_info);
uint32_t cur_idx = 0;
while (cur_info) {
if (0 == cur_idx) { m_name = cur_info; }
else if (1 == cur_idx) { m_sex = cur_info; }
else if (2 == cur_idx) { m_age = stoul(cur_info); }
else { break; }
cur_info = strtok_s(nullptr, " ", &ctx_info);
++cur_idx;
}
}
};
void studentJsonPrinter(const vector<Student>& students) {
cout << "{\n";
for (auto& stu : students) {
stu.jsonPrinter();
}
cout << "}\n";
}
int main() {
vector<Student> students;
char stu_buf[1024] = { '\0' };
cin.getline(stu_buf, 1024);
char* ctx_stu = nullptr;
char* cur_stu = strtok_s(stu_buf, ",", &ctx_stu);
while (cur_stu) {
students.emplace_back(cur_stu);
cur_stu = strtok_s(nullptr, ",", &ctx_stu);
}
studentJsonPrinter(students);
return 0;
}
运行结果如下:
好了,暂时就分析到这了,进一步分析strtok的源码和线程安全,请移步下一篇博客。
最后一个需要注意的小点
这个题,在输出四年平均成绩(浮点数)的时候是遵循:有几位小数,就输出几位小数的。并非保留整数部分,也并非其他情况。所以,不建议用printf格式输出,而是直接用cout,程序会自行打印到具体的小数位。
第七题:
第七题分析:
这个题要求输出9 22 5,但是题目实例化对象中,输入的数据是5,20,5,如何才能得到9 22 5呢?首先,我们要分析Sample类需要补充哪些函数,以及main函数会调用哪些函数。
main函数会调用哪些函数?
Sample a(5); // 调用有参构造
Sample b = a; // 调用拷贝构造
PrintAndDouble(b); // 调用拷贝构造
Sample d;// 调用无参构造
d = a; // 调用赋值操作符函数
需要补充Sample类的哪些函数?
构造:无参构造和有参构造可以一起写,把有参改成默认值为0即可。
赋值:类会有一个默认的,浅拷贝的赋值操作符函数,这里看实际情况要不要重写。
如何得到9 22 5?
我们发现,9是通过调用PrintAndDouble(b);得到的,这里有两次拷贝构造。最开始的拷贝构造Sample b = a; 是将a的值赋给b,这里b的值是5。不难想象,一次拷贝构造,会在原本值的基础上加2,连续两次拷贝则加4,因此得到9。
如何,按照上面的分析,打印c时会有拷贝构造,因此又加2,得到22。而5并没有发生变化,因此无需重写赋值运算操作符。
本题AC的代码如下:
Sample(int v_ = 0) : v(v_) { }
Sample(const Sample& s) : v(s.v + 2) { }
这里不禁有同学会问,为什么不要重写赋值操作运算符呀?难道不会有浅拷贝危害吗?这里要强调的是,浅拷贝危害,只会出现在成员变量是指针变量的情况!因为:浅拷贝危害是由指针直接赋值而带来的潜在危害。
第八题:
第八题分析:
首先,一定会输出一个123,然后不能改main函数,那么123从何而来?可能是从构造函数得来。
然后,输入的m和n,都会变成a的成员变量val,这显然是改变了value的值,并且是通过GetObj这样的函数返回值改动的,这说明:GetObj的返回值是引用类型。
然后,GetObj既能接收一个整数m,又能接收一个对象A(n),说明返回值是A&类型。一个整数m会调用赋值构造函数(即构造函数),但是这个构造函数显然没有输出123,说明这里的有参构造不能和无参构造写成一个函数。
因此,本题需要有补充三个函数:
1)一个无参构造函数,将val赋予默认值(可不做),并输出123;
2)一个有参构造函数,用来将整数m变成对象A(m);
3)一个可改变this指向的对象的函数GetObj,返回*this,类型是A&。
AC的代码如下(为了看起来美观,将整个类A的设计放进来了):
class A {
public:
int val;
A(int
v = 123) : val(v) { }
A& GetObj() { return *this; }
A& operator=(const A& obj) {
if (this == &obj) { return *this; }
val = obj.val;
return *this;
}
};
第九题:
第九题分析:
这个题只需要添加构造函数和赋值运算操作符函数即可,在构造函数中初始化成员变量 r 和 i 。但是这个题很多同学写的太简单了,直接用写:
Complex(const char* str = nullptr) {
if (!str) {
r = i = 0.0;
return;
}
r = (double)(str[0]-'0');
i = (double)(str[2]-'0');
}
这里可能同学们有疑问,main函数里面的函数调用明明是:
1)调用无参构造;
2)调用有参构造,初始化匿名对象
3)调用赋值操作运算符函数
这里不实现1)无参构造还可以理解,因为有默认值nullptr了,注意默认值就要有默认值的处理逻辑!那为什么不实现3)赋值操作运算符的重载呢?
之前提过,这里再次强调:C++的每一个类,都会有一个默认的,浅拷贝的赋值操作运算符函数。然而,这个类Complex,没有成员指针变量,因此可以用浅拷贝(即默认)的赋值操作运算符函数。
第九题的升级版
现在要求,对于任意的输入,甚至可能包含空格,解析出复数,例如:
【输入】
1.234+2.5678i
8765i
45.1024
245412+365102.855i
【输出】
1.234+2.5678i
0+8765i
45.1024+0i
245412+365102.855i
这本质上是一个字符串分割和字符串转浮点数的问题,如果要用strtok_r或者strtok_s(根据系统而定)可能会出现段错误的问题,这里需要注意:使用线程安全的字符串分割strtok函数,不能输入字符串常量作为被分割的串。
代码实现如下:
Complex(const char* str = nullptr) : r(0), i(0) {
if (!str) { return; }
char* buf = new char[strlen(str) + 1]();
strcpy(buf, str);
char* ctx1 = nullptr, * ctx2 = nullptr;
char* cur_r = strtok_s(buf, "+", &ctx1);
r = atof(cur_r);
char* cur_i = strtok_s(ctx1, "i", &ctx2);
i = atof(cur_i);
delete[] buf;
buf = nullptr;
}
代码提交时,需要注意:OJ测评机是Linux系统,应使用strtok_r而并非strtok_s,只有在Windows本地编写代码时,才用strtok_s。