关于IO流的知识,我们在这里只是简单地提一下,以及在代码书写过程当中需要注意的问题。关于其具体的、详细的知识,我们放在Linux操作系统中来介绍。
1、IO流
1-1 C语言的输入与输出
C语言中我们用到的最频繁的输入输出方式就是scanf ()与printf()。
scanf(): 从标准输入设备(键盘)读取数据,并将值存放在变量中。
printf(): 将指定的文字/字符串输出到标准输出设备(屏幕)。同时需要注意宽度输出和精度输出控制。
C语言借助了相应的缓冲区来进行输入与输出。
对输入输出缓冲区的理解:
1.可以屏蔽掉低级I/O的实现,低级I/O的实现依赖操作系统本身内核的实现,所以如果能够屏蔽这部分的差异,可以很容易写出可移植的程序。
2.可以使用这部分的内容实现“行”读取的行为,对于计算机而言是没有“行”这个概念,有了这部分,就可以定义“行”的概念,然后解析缓冲区的内容,返回一个“行”。
如下图:
(我们在这里简单了解一下,在操作系统章节我们还会详细介绍)
1-2 流的概念
“流”即是流动的意思,是物质从一处向另一处流动的过程,是对一种有序连续且具有方向性的数据( 其单位可以是bit,byte,packet )的抽象描述。
C/C++流是指信息从外部输入设备(如键盘)向计算机内部(如内存)输入和从内存向外部输出设备(显示器)输出的过程。这种输入输出的过程被形象的比喻为“流”。
它的特性是:有序连续、具有方向性。为了实现这种流动,C++定义了I/O标准类库,这些每个类都称为流/流类,用以完成某方面的功能
1-3 C++ IO 流
C++系统实现了一个庞大的类库,其中ios为基类,其他类都是直接或间接派生自ios类
如图:
我们接下来挑几个来说说。
1-3-1 C++标准IO流
C++标准库提供了4个全局流对象cin、cout、cerr、clog,
使用cout进行标准输出,即数据从内存流向控制台(显示器)。使用cin进行标准输入即数据通过键盘输入到程序中,同时C++标准库还提供了cerr用来进行标准错误的输出,以及clog进行日志的输出。
从上图可以看出,cout、cerr、clog是ostream类的三个不同的对象,因此这三个对象现在基本没有区别,只是应用场景不同。
在使用时候必须要包含文件并引入std标准命名空间。
注意:
1. cin为缓冲流。键盘输入的数据保存在缓冲区中,当要提取时,是从缓冲区中拿。如果一次输入过多,会留在那儿慢慢用,如果输入错了,必须在回车之前修改,如果回车键按下就无法挽回了。只有把输入缓冲区中的数据取完后,才要求输入新的数据。
2. 输入的数据类型必须与要提取的数据类型一致,否则出错。出错只是在流的状态字state中对应位置位(置1),程序继续。
3. 空格和回车都可以作为数据之间的分格符,所以多个数据可以在一行输入,也可以分行输入。但如果是字符型和字符串,则空格(ASCII码为32)无法用cin输入,字符串中也不能有空格。回车符也无法读入。
4. cin和cout可以直接输入和输出内置类型数据,原因:标准库已经将所有内置类型的输入和输出全部重载了:(如图)
5. 对于自定义类型,如果要支持cin和cout的标准输入输出,需要对<< 和 >> 进行重载。
6. 在线OJ中的输入和输出:
对于IO类型的算法,一般都需要循环输入
我们在这里注意到,一个cin其返回值为istream类型。
我们有的时候却需要写成:
while( cin >> a){}
这样的形式。
什么原因呢?
就是说,这里专门有一个函数用于检测其流的状态,返回值为一个bool类型。
所以说,这里,会调用这个函数来去进行一个流状态的判断;如果正常接收,则返回true,如果错误接收,则返回false。
1-3-2 C++文件IO流
注意头文件(fstream)
C++根据文件内容的数据格式分为二进制文件和文本文件。采用文件流对象操作文件的一般步骤:
1. 定义一个文件流对象
ifstream ifile(只输入用)
ofstream ofile(只输出用)
fstream iofile(既输入又输出用)
2. 使用文件流对象的成员函数打开一个磁盘文件,使得文件流对象和磁盘文件之间建立联系
3. 使用提取和插入运算符对文件进行读写操作,或使用成员函数进行读写
4. 关闭文件
举个例子吧:
#include<iostream> #include<fstream> using namespace std; struct Configuer { char _ip[20]; int ch; }; class ConManger { public: ConManger(const char* filename) :_filename(filename) {} void ReadBin(const Configuer& info) { ifstream ifs(_filename); ifs.read((char*)&info, sizeof(info)); ifs.close(); } void Write(const Configuer& info) { ofstream ofs(_filename); ofs.write((char*)&info, sizeof(info)); ofs.close(); } private: string _filename; }; int main() { Configuer sp; Configuer s = { "100.0.01",80 }; ConManger fs("test.cpp"); fs.Write(s); fs.ReadBin(sp); cout << sp.ch << " " << sp._ip; return 0; }
大家有兴趣的话可以看一下。
实际上,这里和C语言已经很像了,只是函数不一样。而且,因为其是通过调用自己的函数,所以其是只能进行二进制的字符读写的。
1-4 stringstream的简单介绍
在C语言中,如果想要将一个整形变量的数据转化为字符串格式,如何去做?
1. 使用itoa()函数
2. 使用sprintf()函数
但是两个函数在转化时,都得需要先给出保存结果的空间,那空间要给多大呢,就不太好界定,而且转化格式不匹配时,可能还会得到错误的结果甚至程序崩溃。
在C++中,对于string类其自己也是有转换的接口不错。
但是觉得还是麻烦。
C++提供了一种更简单的方法:
使用stringstream类对象来避开此问题
在程序中如果想要使用stringstream,必须要包含头文件。在该头文件下,标准库三个类:istringstream、ostringstream 和 stringstream,分别用来进行流的输入、输出和输入输出操作
stringstream主要可以用来:
1-4-1. 将数值类型数据格式化为字符串
#include<sstream> int main() { int a = 12345678; string sa; // 将一个整形变量转化为字符串,存储到string类对象中 stringstream s; s << a; //像流一样输入到s中 s >> sa; //再像流一样输入到sa中 // clear() // 注意多次转换时,必须使用clear将上次转换状态清空掉 // stringstreams在转换结尾时(即最后一个转换后),会将其内部状态设置为badbit // 因此下一次转换是必须调用clear()将状态重置为goodbit才可以转换 // 但是clear()不会将stringstreams底层字符串清空掉 // s.str(""); // 将stringstream底层管理string对象设置成"", // 否则多次转换时,会将结果全部累积在底层string对象中 s.str(""); s.clear(); // 清空s, 不清空会转化失败 double d = 12.34; s << d; s >> sa; string sValue; sValue = s.str(); // str()方法:返回stringsteam中管理的string类型 cout << sValue << endl; return 0; }
1-4-2. 字符串拼接
这就更简单了。
int main() { stringstream sstream; // 将多个字符串放入 sstream 中 sstream << "first" << " " << "string,"; sstream << " second string"; cout << "strResult is: " << sstream.str() << endl; // 清空 sstream sstream.str(""); sstream << "third string"; cout << "After clear, strResult is: " << sstream.str() << endl; return 0; }
1. stringstream实际是在其底层维护了一个string类型的对象用来保存结果。
2. 多次数据类型转化时,一定要用clear()来清空,才能正确转化,但clear()不会将stringstream底层的string对象清空。
3. 可以使用s. str("")方法将底层string对象设置为""空字符串。
4. 可以使用s.str()将让stringstream返回其底层的string对象。
5. stringstream使用string类对象代替字符数组,可以避免缓冲区溢出的危险,而且其会对参数类型进行推演,不需要格式化控制,也不会出现格式化失败的风险,因此使用更方便,更安全
2、C++之继承
假设我们现在存在这样一个场景:
答案当然是否定的。
这就引出了我们的主角——继承。
2-1 继承的概念及定义
2-1-1 继承的概念
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。
这是官方的说法。说人话,就是以前我们接触的复用都是函数复用,
而继承是类设计层次的复用
2-1-2 继承定义
1.2.1定义格式
如下,我们看到Person是父类,也称作基类。Student是子类,也称作派生类
我们来举一个简单的例子:
#include<iostream> using namespace std; class Person { public: void func() { cout << "void func" << endl; } private: int _age; char _name[20]; char _sex[5]; }; class Teacher : public Person { public: void func1() { cout << "void func1" << endl; } private: char tnum[20]; }; class Student :public Person { public: void func2() { cout << "void func2" << endl; } private: char snum[20]; }; int main() { Teacher t; Student s; t.func(); s.func(); return 0; }
我们让代码运行起来,通过监视窗口来看看其内部的成员:
(由于我们没有初始化,其内部的数据还是个随机值)
我们可以看到,这里的编译器实际上是做了一个优化,即将父类的Person简单优化了一下。
2-2 继承关系和访问限定符
我们这里可以注意到,我们这里多了一个protected成员继承。
我们下面就结合例子,详细解释一下:
->继承基类成员访问方式的变化
在上表中,我们两两组合,能够分成九大类。
简单分下类,
1. 基类private成员在派生类中无论以什么方式继承都是不可见的。
这里的不可见是指:
基类的私有成员被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
比如:
2. 基类private成员在派生类中是不能被访问,
如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。
可以看出保护成员限定符是因继承才出现的。
比如,还是上面的例子,我改一下就可以了:
需要注意的是,其在类外还是不允许访问的。
3. 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。并且基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private。
4. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
5. 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用
protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强
2-3.基类和派生类对象赋值转换
派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。
基类对象不能赋值给派生类对象基类的指针,不过可以通过强制类型转换赋值给派生类的指针。
但是必须是基类的指针是指向派生类对象时才是安全的。
2-4.继承中的作用域
1. 在继承体系中基类和派生类都有独立的作用域。
2. 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)
我们来举一个例子:
(上图代码如下)
#include<iostream> using namespace std; class Person { public: void func1() { cout << "void func" << endl; } protected: int _age; char _name[20]; char _sex[5]; }; class Teacher : public Person { public: void func1() { cout << "void func1" << endl; _age = 5; } private: char tnum[20]; }; class Student :public Person { public: void func1() { cout << "void func2" << endl; } private: char snum[20]; }; int main() { Student s; s.func1(); return 0; }
如果我们想要对其进行访问,那么就只需要用 类域:: 成员名 即可
3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
4. 注意在实际中在继承体系里面最好不要定义同名的成员。
2-5 派生类的默认成员函数
6个默认成员函数,“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类中,这几个成员函数是如何生成的呢?
1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。
如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
这就和我们自定义类型的初始化很像。都是去调用原本属于自己的那一部分构造函数去初始化。
2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。这一点来说,其和构造函数是一样的。
我们来举个例子:
像上面的代码那样,通过调试,我们能够感受到其调用构造函数的过程。
(代码如下:)
#include<iostream> #include<string> using namespace std; class Person { public: Person(int age,string name, string sex) :_age(age) ,_name(name) ,_sex(sex) {} protected: int _age; string _name; string _sex; }; class Teacher : public Person { public: Teacher(int age, string name, string sex,string tnum) :Person(age,name,sex) ,_tnum(tnum) {} private: string _tnum; }; int main() { Teacher s(3,"张三","男","00300214"); return 0; }
3. 派生类的operator=必须要调用基类的operator=完成基类的复制。这和拷贝构造基本一致。
4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。类比出栈入栈的顺序。
5. 派生类对象初始化先调用基类构造再调派生类构造。这个我们在第一、二点中已经提到过。
6. 派生类对象析构清理先调用派生类析构再调基类的析构。(这和我们第四点说的一致)
来看这个例子:
我们再这里放上一张图,可以形象的表示出基类、派生类调用的先后关系。
2-6.继承与友元
一句话:
友元关系不能继承,也就是说,父类的友元,子类是无法继承下来的。
举个例子:
#include <iostream> using namespace std; class A{ int a; public: A(int x = 0) { a = x; } friend class B; }; class B { int b; }; class C :public B { public: void fun2(A& p) { cout << p <<endl; //派生类新加的函数却不能访问A,此句会报错 } int main() { A a(55); }
需要注意的是,其在父类中还是可以访问的。
2-7. 继承与静态成员
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。
无论派生出多少个子类,都只有一个static成员实例
2-8.复杂的菱形继承及菱形虚拟继承
这里实际上就比较恶心了。
因为其涉及到菱形继承的存在。
单继承:
一个子类只有一个直接父类时称这个继承关系为单继承
多继承:
一个子类有两个或以上直接父类时称这个继承关系为多继承
实际上,这两种都没有什么好说的。
重点是要说说菱形继承。
菱形继承:
菱形继承是多继承的一种特殊情况。
我们会不会发现这样一个问题:
对于菱形继承,类Assistant好像将类Person中的内容弄了两份。而且,当我在访问的时候,到底算谁的?
也就是说,菱形继承存在这样的问题:
1、数据冗余。
2、二义性。
这也是C++中的一个坑。
而语法又总是需要向前兼容,那么这个坑就一直会存在。
如下图:在Assistant中存在了两个Person
那如何解决这样一个问题呢?
C++给出了一种更加复杂的解决方案——用虚拟继承。
就是说,我在继承的时候加上一个关键字virtual.
我们来举个例子:
然后借助这个例子,借助内存窗口,研究一下虚拟继承是如何实现的。
如果你不加上virtual,你会发现,实例化后的d在内存中会有两份_a。
但是如果加上,就只有一个_a,
可以存储在类d的最下面(也可以是最上面,在该环境下是最下面),
然后继承下来每一个类(即类B和类C)在原有父类的内存的位置,换成一个地址,该地址叫做虚基表指针,该指针指向一个表,这个表叫做虚基表,在虚基表中记录着该位置与_a位置的偏移量。
图示可以理解为:
虚拟继承可以解决菱形继承的二义性和数据冗余的问题。
如上面的继承关系,在Student和Teacher的继承Person时使用虚拟继承,即可解决问题。
需要注意的是,虚拟继承不要在其他地方去使用。
2-8 继承的总结和反思
1. 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。
2. 多继承可以认为是C++的缺陷之一,很多后来的OO语言都没有多继承,如Java。
3. 继承和组合
public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。
组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。
优先使用对象组合,而不是类继承 。
继承允许你根据基类的实现来定义派生类的实现。
这种通过生成派生类的复用通常被称为白箱复用术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。
所以,继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。
派生类和基类间的依赖关系很强,耦合度高。
对象组合是类继承之外的另一种复用选择。
新的更复杂的功能可以通过组装或组合对象来获得。
对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用,因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。 组合类之间没有很强的依赖关系,耦合度低。
优先使用对象组合有助于你保持每个类被封装。
实际尽量多去用组合。组合的耦合度低,代码维护性好。
不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。
类之间的关系可以用继承,可以用组合,就用组合