Loading

C++知识点汇总

一、C++ 简介

程序设计语言分为:

  • 低级语言(机器语言、汇编语言)
  • 中级语言
  • 高级语言(C、C++等)

C++ 语言的主要特点:

  • 兼容 C 语言
  • 面向对象(继承和多态)
  • 引进了类和对象的概念

C++ 的基本数据类型

  • bool:布尔值
  • char:字符型
  • int:整型
  • float:浮点型
  • double:双精度浮点型

注释的两种方式:

  • 单行注释(//
  • 多行注释(/*..*/

编写 C++ 程序一般需要经过四个步骤,依次是:编辑、编译、连接、运行。

1. 头文件和命名空间

包含头文件需要使用 # include 指令,一条指令可以包含一个头文件,多个头文件需要使用多条指令。

通常使用尖括号 <> 包含系统头文件,会首先在系统设定的目录中寻找要包含的头文件;

使用双引号 "" 包含自定义的头文件,会在当前用户目录下或指令中指定的目录下寻找要包含的头文件。

# include <iostream>
# include "myCustom.h"

常用的头文件:

  • 标准输入/输出流:<iostream>
  • 标准文件流:<fstream>
  • 标准字符串处理函数:<string>
  • 标准数学函数:<cmath>

文件后缀 .cpp 是源程序文件,文件后缀 .h 是头文件。

命名空间的作用是消除同名引起的歧义。

using namespace std;

// 自定义命名空间
namespace work {  // work 为命名空间名
    // 各种声明
    class Foo {...};  
    func() {...};
};

// 使用方式一
using work::func();

// 使用方式二 推荐
using namespace work;
Foo f;  // 文件头声明后后面可以直接使用
func();
2. 基本的输入/输出

当程序需要进行输入/输出信息时,需要包含头文件

  • cin:使用流提取运算符 >> 从标准输入设备键盘取得数据;

  • cout:使用流插入运算符 << 向标准输入设备屏幕输出信息。

# include <iostream>
using namespace std;

int main() {
    int a, b;
    cin>>a>>b;
    cout<<"a="<<a<<"\tb="<<b<<endl;
    return 0;
}
3. 强制类型转换运算符

static_cast 把表达式类型转换为类型名所指定的类型,static_cast 也可以省略。

double num = 3.12;
n1 = static_cast<int>(num);  // 强制类型转换
n2 = int(num);               // 强制类型转换运算符的新形式
n3 = (int) num;              // 强制类型转换运算符的旧形式
n4 = num;                    // 自动类型转换

image-20230321222251985

4. 函数参数的默认值

C++ 语言规定,提供默认值必须按从右至左的顺序提供,有默认值的形参必须在最后。

void func(int a, int b=2, int c=3);  // 正确
void func(int a=1, int b);  // 错误,a 有默认值形参应该放在最后
void func(int a, int b=2, int c); // 错误,b 有默认形参应该放在最后

调用函数时,主调函数的实参与被调函数的形参按从左至右的顺序进行匹配对应。

int func(float x, char y='$', int a=9, char b='@');

// 函数调用判断
func(3.14);  // 正确,仅匹配 x,其他都有默认值
func(3.14, '#');  // 正确,匹配 x、y 
func(3.14, '%', '@');  // 错误,第三个参数类型不对应,预期 int,实际是 char
func(3.14, '&', 5, '*');  // 正确
func(3.14, , 5, '*');  // 错误,调用时的实参应该是连续排列的
5. 引用

引用相当于给变量起了个别名,对应于某个内存地址。如果给某个变量起了别名(不需要给它另开辟内存单元),相当于变量和这个引用都对应到同一地址

// 在程序中定义变量的引用
// 类型名 &引用名 = 同类型的某变量名;
# include <iostream>
using namespace std;

int main() {
    int foo = 1;
    int &ref = foo;  // ref 是 foo 的引用,等价于 foo
	const int &ref2 = foo;  // 定义常引用
    
    ref = 2;  // foo=2; ref=2; ref2=2;
    foo = 3;  // foo=3; ref=3; ref2=3;
    // ref2 = 4;  错误,不能使用常引用对所引用的变量进行修改
    
    return 0;
}

引用还可以用在函数中,既可以作为函数的参数使用,也可以作为函数的返回值使用。函数调用时参数的传递方式有两种:传值传引用

传值,传递对象的。将实参的值拷贝给形参,函数执行过程中,都是对这个拷贝进行操作的,执行完毕后,形参的值并不拷贝回实参。也就是函数内部对形参的改变不会影响到函数外实参的值。

传引用,传递对象的首地址值。函数调用时,实参对象名传递给形参对象名,形参就成为实参的引用,他们是等价的,代表同一个对象。也可以看作是将实参的地址传递给了形参,函数内部对形参进行的改变,会影响到函数外实参的值。引用调用形参必须是引用。

// 函数中使用引用

/* 例 1:引用作为参数传递 */
# include <iostream>
using namespace std;

void func(int x, int y) {  // 传值
    int tmp;
    tmp = x; x = y; y = tmp;
    cout<<"func(): "<<"a="<<x<<" b="<<y<<endl;
}

void func_ref(int &x, int &y) {  // 传引用
    int tmp;
    tmp = x; x = y; y = tmp;
    cout<<"func_ref(): "<<"a="<<x<<" b="<<y<<endl;
}

int main() {
    int a = 10, b = 20;
    
    func(a, b);
    cout<<"调用 func() 后: a="<<a<<" b="<<b<<endl;
    // func(): a=20 b=10
    // 调用 func() 后: a=10 b=20
    // 形参的改变没有影响函数外实参的值
    
    func_ref(a, b);
    cout<<"调用 func_ref() 后: a="<<a<<" b="<<b<<endl;
    // func_ref(): a=20 b=10
    // 调用 func_ref() 后: a=20 b=10
    // 形参的改变影响了函数外实参的值
    
    return 0;
}

/* 例 2:引用作为返回值 */
# include <iostream>
using namespace std;

int a = 10, b = 20;

int &ref(int &x) {  // 返回值是引用
    return x;  
}

int main() {
    ref(a) = 30;
    cout<<"a="<<a<<" b="<<b<<endl;   // a=30 b=20
    
    ref(b) = 40;
    cout<<"a="<<a<<" b="<<b<<endl;  // a=30 b=40
    
    return 0;
}
6. const 与指针共同使用

const 用于约束某值不变,在 C++ 中是用来修饰内置类型变量,自定义对象,成员函数,返回值,函数参数。

// const 修饰普通变量
const int a = 10;
int b = a;  // 正确
a = 8;  // 错误 不能改变
// a 被定义为一个常量
// 可以将 a 赋值给 b,但是不能对 a 再次赋值,不允许对常量重新赋值

const 修饰指针变量

情况一:左定值,const 修饰指针指向的内容,则内容为不可变量。

const int *p = 8;

如果唯一的 const 位于符号 * 的左侧,表示指针所指数据是常量,数据不能通过本指针改变,但可以通过其他方式修改。指针本身是变量,可以指向其他的内存单元。

情况二:右定向,const 指针指向的内存地址不能被改变,但其内容可以改变。

int a = 8;
int *const p = &a;
*p = 9;  // 正确,内容可改变
int b = 7;
p = &b;  // 错误,指针地址不能被改变

如果唯一的 const 位于符号 * 的右侧,表示指针本身是常量,不能让该指针指向其他内存地址。指针所指的数据可以通过本指针进行修改。

情况三:内容和指针内存地址都固定,不可改变。

int a = 8;
const int *const p = &a;
int const *const p = &a;

如果在 * 的左右各有一个 const 时,表示指针和指针所指的数据都是常量,既不能让指针指向其他地址,也不能通过指针修改所指向的内容。

7. 内联函数

为避免频繁的函数调用,使用内联函数,在编译时不生成函数调用,而是将程序中出现的每一个内联函数表达式替换为该内联函数的函数体。使用内联函数会使最终可执行程序的体积增大,以空间消耗节省时间开销。

定义内联函数需要在函数头加上关键字 inline,定义在前,调用在后。内联函数主要应用于代码量少且频繁调用的函数,通常不建议内联函数体中包含循环语句或 switch 语句。

# include <iostream>
using namespace std;

inline int Max(int x, int y) {
    return x > y ? x : y;
}

int main() {
    cout<<Max(20, 10)<<endl;
    cout<<Max(100, 500)<<endl;
}

如果函数成员定义在类体内,则默认是内联函数。也可以在类体内部声明函数,并加上 inline 关键字,然后在类体外给出定义,这样也是内联函数。

# include <iostream>
using namespace std;

class A {
    public:
        inline void print1();  // 类体外定义需要加 inline 关键字
        void print2() {  // 默认内联函数
            cout<<"print inline 2"<<endl;
        }
};

void A::print1() {
    cout<<"print inline 1"<<endl;
}

int main() {
    A a;
    a.print1();
    a.print2();
}
8. 函数重载

函数重载是指在程序的同一范围内声明几个功能类似的同名函数,提高代码可读性。必须要满足条件之一:

  • 参数表中参数类型不同(顺序不同也可)
  • 参数表中参数个数不同
# include <iostream>
using namespace std;

int max(int x, int y) {
    return x > y ? x : y;
}

int max(float x, float y) {
    return x > y ? x : y;
}

int main() {
    cout<<max(5, 8)<<endl;
    cout<<max(3.14, 5.67)<<endl;
}

如果两个函数的名字和参数表都是一样的,仅仅是返回值类型不同,则不符合函数重载的条件,编译报错。

// 错误的函数重载
float max(float x, float y);
int max (float x, float y);

采用引用参数也不符合函数重载。

// 错误的函数重载
void print(double);
void print(&double);

避免产生二义性

// 错误的函数重载
int sum(int a, int b, int c=0);
int sum(int a, int b);

sum(1, 2);  // 编译错误,不知道调用哪个函数
9. 指针和动态内存分配

C++ 中使用 new 运算符实现动态内存分配。指针变量中保存的是一个地址,也称指针指向一个地址。

int *p;
p = new int;  // 动态分配 4 字节的内存空间
*p = 5;

使用 new 运算符也可以动态分配一个任意大小的数组。数组的长度是声明数组时指定的,不允许定义元素个数不明确的数组。

int pArr;
int n;
pArr = new int[n];  // 错误,元素个数不明确
pArr = new int[5];  // 分配了 5 个元素的整型数组
pArr[0] = 10;  // 数组的第一个值
pArr[4] = 20;  // 数组的最后一个值

使用 pArr[-1] 或者 pArr[5]时,下标会越界。不过在编译时,对于数组越界的错误不会提示,运行时报错。

使用 new 运算符动态申请的内存空间,需要在使用完毕后释放。使用 delete 运算符,用来释放动态分配的内存空间。

/* 释放指针变量动态内存 */
int foo = 6;
int *p = &foo;
delete p;  // 错误,delete 后面的指针必须是指向动态分配的内存空间(new)

int *q = new int;
*q = 8;
delete q;  // 正确,q 指向动态分配的空间

/* 释放数组动态内存 */
int *p = new int[100];
delete []p;
10. string 对象

C++ 标准模板库中提供了 string 数据类型,专门处理字符串。string 是一个类,这个类型的变量称为 string 对象

# include <string>  // 需包含头文件

// 使用 string 类型初始化变量
string str = "hello";
string str2 = "world";

// 使用字符数组对 string 变量初始化
char name[] = "hello, world.";
string str = name;

// 声明 string 对象数组
string citys[] = {"beijing", "shenzhen", "shanghai"};

str.empty()           // 判断字符串是否为空 true false
str.length()          // 返回字符串长度
str.size()            // 返回字符串占用空间字节数
str.append("haha")    // 向字符串后面追加内容
str.insert(4, "123")  // 从字符串第四个位置插入内容

string 对象间可以相互赋值,不需要考虑空间是否足够的问题。

11. C++ 语言的程序结构

C++程序以 .cpp 作为文件扩展名,文件中包含若干个类和若干个函数。程序中必须有且仅有一个主函数 main(),这是程序执行的总入口。程序从主函数的开始处执行,直到结束。主函数可以出现在任何地方。

程序的结束通常是遇到了以下两种情况:

  • 主函数中遇到了 return 语句
  • 执行到了主函数最后的括号

主函数可以调用其他函数,但其他函数不能调用主函数。主函数仅是系统执行程序时调用的。

二、面向对象的基本概念

结构化程序设计方法采用自顶向下、逐步求精及模块化思想,大问题化小问题。

编写程序时使用 3 种基本控制结构:顺序、选择、循环,强调程序的易读性。

面向对象程序设计方法就是使分析、设计和实现一个系统的方法 尽可能地接近 人们认识一个系统的方法。通常包括三方面:面向对象的分析、面向对象的设计、面向对象的程序设计

对象具有两个特性:

  • 状态,指对象本身的信息(属性);
  • 行为,指对对象的操作。

通过对实物的抽象找出同一类对象的共同属性(静态特征)行为(动态特征),从而得到类的概念。对象是类的一个具象,类是对象的一个抽象。C++ 中使用 对象名、属性、操作 三要素来描述对象。

面向对象的程序设计有四个基本特点:

  • 抽象:对象的属性和操作

  • 封装:通过自定义类来支持数据封装和信息隐藏

  • 继承:在已有类的基础上加上特殊的数据和函数构成新类,原来的类是基类(父类或超类),新类是派生类(子类)

  • 多态 :不同种类的对象具有名称相同的行为,但具体的实现方式却不同。通过函数重载及运算符重载实现的多态。

1. 类的定义

类是具有唯一标识符的实体,类名不能重复

标识符命名规则:字母、数字、下划线 的组合,但不能以数字开头,大小写敏感,不能和系统中的关键字重名。类定义以 ; 结束,大括号中的部分称为类体。

定义类时系统并不为类分配存储空间,类中声明的任何成员不能使用 auto、extern、register 关键字进行修饰。

类中的成员按功能划分:

  • 成员变量:对象的属性,个数不限,也称为数据成员。成员变量的声明方式与普通变量的声明方式相同;
  • 成员函数:对象的操作,个数不限,声明方式与普通函数相同。

类中的成员按访问权限划分:

  • 公有成员(public):公有的,可以在程序任何地方访问;
  • 私有成员(private) :私有的,仅能在本类内访问;未定义则默认为私有;
  • 保护成员(protected):保护的,能在本类内及子类中被访问。

成员函数可以定义在类体内,也可以定义在类体外。可以定义不是任何类的成员的函数,称为全局函数

如果成员函数定义在类体外,则类体内必须要有函数原型声明,类体外定义函数必须使用类作用域运算符 ::。成员函数在内存中只有一份,可以作用于不同的对象,为类中各对象共享

#include <iostream>
using namespace std;

class A {
    int foo = 1;  // 定义成员变量,默认为私有成员
    
    public:  // 共有成员
    	void print();  // 类体内声明成员函数
    	A a;  // 错误,不能定义本类的成员变量
};  // 注意类定义最后要加引号

void A::print() {};  // 类体外定义成员函数

2. 创建类对象的基本形式

class Test {
    public:
    	Test();
    	Test(int x);
};

/* 方法一 */
Test t1;            // 类名 对象名;
Test t2(5);         // 类名 对象名(参数);
Test t3 = Test(6);  // 类名 对象名 = 类名(参数);
Test t4, t5, t6(7), t7(10);  // 扩展多个对象

/* 方法二 */
Test *p1 = new Test;     // 类名 *对象指针名 = new 类名;
Test *p2 = new Test();   // 类名 *对象指针名 = new 类名();
Test *p3 = new Test(5);  // 类名 *对象指针名 = new 类名(参数);

new 创建对象时返回的是一个对象指针,指向创建的对象。创建的对象必须用 delete 来撤销。

// 声明对象的引用
Test t1, t2;    // 定义对象
Test &t = t1;  // 声明对象的引用 ==> 类名 &对象引用名 = 对象;
Test *p = &t2;  // 声明对象指针  ==> 类名 *对象指针名 = 对象的地址;
Test ts[3];     // 声明对象数组  ==> 类名 对象数组名[数组大小];

3. 访问对象的成员

定义了类对象后,就可以访问对象的成员。

#include <iostream>
using namespace std;

class Student {
    int age;
    public:
        char msg[40] = "该学生年龄为:";
        int getAge();
        void setAge(int);
};

int Student::getAge() {
    return age;
}

void Student::setAge(int x) {
    age = x;
}
3.1 通过对象访问
  • 对象名.成员变量名
  • 对象名.成员函数名(参数表)
int main() {
    Student s;
    s.setAge(18);  // 成员函数 
    cout<<s.msg;   // 成员变量
    cout<<s.getAge()<<endl; 
    
    return 0;
}
3.2 通过指针访问

还可以使用指针或引用的方式来访问类成员,运算符 . 需要更换为 ->

int main() {
    Student s;
    Student *p = &s;
    p -> setAge(19);
    cout<<p->msg;
    cout<<p->getAge()<<endl;

    return 0;
}
3.3 通过引用访问
// 与通过对象访问方式一样
int main() {
    Student s;
    Student &sr = s;
    sr.setAge(20);
    cout<<sr.msg;
    cout<<sr.getAge()<<endl;
    
    return 0;
}

4. 标识符的作用域与可见性

标识符是组成程序的最小成分之一。类名、函数名、变量名、常量名和枚举类型的取值等都是标识符。

标识符的作用域有:

  • 函数原型作用域:函数声明时的形参,这是最小的作用域;
  • 局部作用域(块作用域):代码块内,比如循环语句内变量;
  • 类作用域
  • 命名空间作用域

类作用域有三种访问方式:

  • 该类内的成员函数可以直接访问
  • 在类外,通过类.成员类::成员访问
  • 在类外,通过类指针名->成员访问

具有命名空间作用域的变量称为全局变量。命名空间作用域有两种访问方式:

  • 命名空间名::成员;
  • using 命名空间名::成员;
  • using namespace 命名空间名;

作用域的隐藏规则如下:

  • 标识符声明在前,引用在后;

  • 同一作用域中,不能声明同名标识符;

  • 不同作用域中,可以声明同名标识符;

  • 在具有包含关系的两个作用域中,外层声明的标识符:

    • 如果没有在内层重新声明,外层标识符依然在内层可见;
    • 如果在内层重新声明,则内层标识符隐藏外层同名标识符,这种机制称为隐藏规则

类和对象进阶

1. 构造函数

基本数据类型的变量初始化:

  • 全局变量:声明时没有初始化,则系统自动为其初始化为 0
  • 局部变量:声明时没有初始化,则是一个随机值
构造函数的作用

对象的初始化,需要通过构造函数机制,来为对象成员变量赋初值。构造函数是类中的特殊成员函数,给出类定义时,需要编写构造函数,如果没有,则默认由系统添加一个不带参数的构造函数

声明对象后,使用 new 运算符为对象进行初始化,此时系统自动调用构造函数,完成对象的初始化工作,保证对象的初始状态是确定的。

构造函数的定义

定义一个类时,需要为类定义相应的构造函数。构造函数的函数名与类名相同,没有返回值

一个类的构造函数可以有多个,允许重载,参数表一定不能完全相同

当类中没有定义任何构造函数时,系统会自动添加一个参数表和函数体都为空的默认构造函数。因此,任何类都保证至少有一个构造函数

class myDate {};

// 定义构造函数 方式一 无参数
myDate::myDate() {
    year = 1970; month = 1; day = 1;
}

// 方式二 有参数 函数体内赋值
myDate::myDate(int y, int m, int d) {
    year = y; month = m; day = d;
}

// 方式三 另一种写法
myDate::myDate(): year(1970), month(1), day(1) {}  // 赋初始值
myDate::myDate(int y, int m, int d): year(y), month(m), day(d) {}  // 从参数列表取值
构造函数的使用

创建类的任何对象时都一定会调用构造函数进行初始化。如果程序中声明了对象数组,那么数组的每个元素都是一个对象,每个元素都要调用构造函数进行初始化。如果通过类仅声明了指针,并未与对象相关,则不会调用构造函数。

// Test 是类
// 调用 4 次构造函数,声明指针不会调用
Test a(4), b[3], *p;
复制构造函数

复制构造函数是构造函数的一种,也称为拷贝构造函数。作用是使用一个已存在的对象去初始化另一个正在创建的对象。

例如:类对象间的赋值是由复制构造函数实现的。

复制构造函数只有一个参数,参数类型是本类的引用。一个类中可以写两个复制构造函数,函数的参数分别为 const 引用和非 const 引用。

以下三种情况会自动调用复制构造函数:

  • 用一个对象去初始化另一个对象
  • 作为函数形参的对象
  • 作为函数返回值的对象
class A{
    public:
        int x;
        A(int t) {x = t;}  // 有参构造函数
        A(A &t) {x = t.x;}  // 复制构造函数一
        A(const A &t) {x = t.x;};  // 复制构造函数二
};

int main() {
    A a(10);
    cout<<a.x<<endl;  // 10
    A b(a);
    cout<<b.x<<endl;  // 10
    return 0;
}

2. 析构函数

析构函数也是成员函数的一种,名字与类名相同,但要在类名前加一个 ~ 符号,以区别构造函数。

析构函数没有参数,也没有返回值。一个类中有且仅有一个析构函数。如果未定义,则系统自动生成函数体为空的默认析构函数。

析构函数的作用是做一些善后处理的工作,当对象消亡时自动调用析构函数。比如通过 new 创建的对象,使用 delete 释放空间时,首先调用对象的析构函数,然后再释放对象占用的空间。

对于对象数组,要为它的每个元素调用一个构造函数和析构函数。析构函数的调用执行顺序与构造函数正好相反

#include <iostream>
using namespace std;

class Test {
    public:
        Test();
        ~Test();
    private:
        int *p;
};

Test::Test() {
    cout<<"Test 构造函数"<<endl;
    p = new int[10];  // 指针指向堆空间
}

Test::~Test() {
    cout<<"Test 析构函数"<<endl;
    delete [] p;  // 必须显式的声明析构函数,释放空间,避免内存泄漏
}

int main() {
    Test t;
    return 0;
}

3. 变量及对象的生存期和作用域

全局变量

  • 未赋初值默认为 0,字符型变量为空字符
  • 作用域:定义在函数外,可被所有文件的函数使用,其他文件使用需 extern 声明(外部链接)
  • 生存期:整个程序执行期
  • 不同文件的全局变量不可以重名

局部变量

  • 未赋初值,内容为随机
  • 作用域:程序块内
  • 生存期:程序块执行期
  • 同一文件中全局变量和局部变量可以重名,在局部变量作用域内,全局变量不起作用

静态全局变量

  • 值只初始化一次,未赋初值默认为 0,字符型变量为空字符
  • 作用域:本文件内,存储在全局数据区
  • 生存期:整个程序执行期
  • 不同文件的静态全局变量可以重名

静态局部变量

  • 值只初始化一次,未赋初值默认为 0,字符型变量为空字符
  • 作用域:程序块内,存储在全局数据区
  • 生存期:整个程序执行期

使用 new 创建的变量具有动态生存期,从声明处开始,直到用 delete 释放存储空间或程序结束。

类对象的生存期为调用构造函数开始到消亡时调用析构函数。

4. 类的静态成员

类的静态成员分为:

  • 静态成员变量
  • 静态成员函数

类的静态成员只有一份保存在公用内存中,被类的所有对象共享。静态成员定义时,需要在前面添加 static 关键字。必须在类体外赋静态成员变量的初值。

#include <iostream>
using namespace std;

class Book {
    public:
        static int page_num;  // 静态数据成员
        static void print() {  // 静态函数才能调用静态变量
            cout<<"已阅读到的页码为:"<<page_num<<endl;
        }
};

int Book::page_num = 100;  // 静态数据成员的初值只能在类体外定义,不需要加 static 关键字

int main() {
    Book b1, b2, *b3;

    b1.print();  // 100
    b2.print();  // 100 该类的所有对象公用一个静态数据成员

    // 静态成员访问的三种方法
    cout<<Book::page_num<<endl;  // 类名.静态成员名
    cout<<b1.page_num<<endl;     // 对象.静态成员名
    cout<<b3->page_num<<endl;    // 对象指针->静态成员名
    return 0;
}

类的静态函数只能处理类的静态成员变量。静态函数与静态函数之间、非静态函数与非静态函数之间是可以相互调用的,非静态成员函数内可以调用静态成员函数,但静态成员函数内不能调用非静态成员函数。

5. 常量成员和常引用成员

在类中,可以使用关键字 const 定义成员变量、成员函数、类的对象。

类的常量成员变量必须进行初始化,且只能通过构造函数的成员初始化列表的方式进行。

定义常量成员变量或常量对象:const 数据类型 常量名 = 表达式;

定义常量函数:类型说明符 函数名(参数表) const;

对象被创建以后,常量成员变量的值不允许被修改,只可以读其值。对于常量对象,只能调用常量函数

class A {
public:
    void test() {}  // 非常量成员函数
    void demo() const {}  // 常量成员函数
};

int main() {
    const A a;
    a.test();  // 错误 常量对象不能调用非常量成员函数
    a.demo();  // 正确
}

6. 成员对象和封闭类

一个类的成员变量如果是另一个类的对象,则该成员变量称为成员对象。这两个类为包含关系,包含成员对象的类叫做封闭类

#include <iostream>
using namespace std;

class Tyres {
    private:
        int radius, width;
    public:
        Tyres(int r, int w): radius(r), width(w) {
            cout<<"Tyres(radius="<<radius<<", width="<<width<<")"<<endl;
        };
};

class Car {
    private:
        int prices;
        Tyres tyres;
    public:
        Car(int p, int tr, int td);
};

// 定义封闭类构造函数中,需要指明调用成员对象的哪个构造函数 如:Tyres(int, int)
Car::Car(int p, int tr, int td): prices(p), tyres(tr, td) {
    cout<<"Gogogo!"<<endl;  // 先调用成员对象的构造函数,在调用封闭类对象的构造函数
}

int main() {
    Car car(100, 3, 6);
    return 0;
}

// Tyres(radius=3, width=6)
// Gogogo!

7. 友元函数

设置私有成员的机制叫做隐藏。修改私有属性需要通过公有函数,函数内可以避免对对象的不正确操作或做一些其他修改。私有类型的成员在类外不能访问,通过类内公有函数可以访问但是比直接访问的效率低,所以提供了友元访问方式。

友元函数内部可以直接访问本类对象的私有成员,友元函数不是类的成员函数,但允许访问类中的所有成员。不受类中的访问权限关键字限制,可以把它放在类的公有、私有、保护部分,结果是一样的。友元的概念破坏了类的封装性和信息隐藏,但有助于数据共享,能够提高程序执行的效率。

友元函数使用关键字 friend 标识,定义方式 :

  • friend 返回值类型 函数名(参数表);

  • friend 返回值类型 类名::函数名(参数表);

一个函数可以声明为多个类的友元函数,一个类中也可以有多个友元函数。

友元类

如果将一个类 B 说明为类 A 的友元类,则类 B 中的所有函数都是类 A 的友元函数,在类 B 的所有成员函数中都可以访问类 A 中的所有成员。

声明格式为:friend class 类名;

友元类的关系是单向的,友元类的关系不能传递。一般不把整个类说明为友元类。

8. this 指针

当调用一个成员函数时,系统自动向它传递一个隐含的参数,该参数是一个指向调用该函数的对象的指针,称为 this 指针,从而使成员函数知道对哪个对象进行操作。

  • 非静态成员函数内部可以直接使用 this 关键字,代表指向该函数所作用的对象的指针

  • 静态成员函数没有 this 指针

  • 一般情况下,可以省略 this->,系统采用默认设置

运算符重载

运算符重载的概念

算术运算符包括:+ - * / %,通常只能用于对基本数据类型的常量或变量进行运算,而不能用于对象之间的运算。运算符重载可以使运算符也能用来操作对象

可重载的运算符
image

不可重载的运算符
image

重载运算符有一个返回类型和一个参数列表,这样的函数称为运算符函数。运算符可以被重载为全局函数,也可以被重载为类的成员函数。声明为全局函数时,通常应是类的友元。运算符函数是一种特殊的友元函数或成员函数

重载运算符的规则
  • 符合原有的用法习惯
  • 不能改变运算符原有的语义
  • 不能改变运算符操作数的个数及语法结构
  • 不能创建新的运算符
  • 重载运算符() [] -> = 时,只能重载为成员函数,不能为全局函数
  • 不能改变运算符用于基本数据类型对象的含义
myComplex operator+(const myComplex & c1, const myComplex & c2) {
	return myComplex(c1.real + c2.real, c1.imag + c2.imag); 
}
myComplex operator+(const myComplex& c1, double r) {
	return myComplex(c1.real + r, c1.imag); 
}
myComplex operator+(double r, const myComplex& c1) {
	return myComplex(r + c1.real, c1.imag); 
}

myComplex operator-(const myComplex& c1, const myComplex& c2) {
	return myComplex(c1.real - c2.real, c1.imag - c2.imag); 
}
myComplex operator-(const myComplex& c1, double r) {
	return myComplex(c1.real - r, c1.imag); 
}
myComplex operator-(double r, const myComplex& c1) {
	return myComplex(r - c1.real, -c1.imag); 
}
重载赋值运算符

赋值运算符 = 只能重载为成员函数。

myComplex& myComplex::operator=(const myComplex& c1) {
	this->real = c1.real; 
    this->imag = c1.imag; 
    return *this;
}
myComplex& myComplex::operator=(double r) {
	this->real = r; 
    this->imag = 0; 
    return *this;
}

同类对象之间可以通过赋值运算符进行赋值。如果没有经过重载,= 的作用就是将赋值号右侧对象的值一一赋值给左侧的对象。这相当于值的拷贝,称为浅拷贝。重载赋值运算符后,赋值语句的功能是将一个对象中指针成员变量指向的内容复制到另一个对象指针成员变量指向的地方,这样的拷贝叫深拷贝

重载流插入运算符和流提取运算符
  • 流插入运算符(cout) <<
  • 流提取运算符(cin) >>

只能采用友元函数重载的方式。

#include <iostream.h> 

class Test
{
	private:
		int i; float f; char ch;
	public:
		test(int a=0, float b=0, char c='\0') {i=a; f=b; ch=c;} 
    	friend ostream &operator<<(ostream &, test);    // 必须重载为类的友元
    	friend istream &operator>>(istream &, test &);  // 必须重载为类的友元
};

ostream &operator<<(ostream &stream, test obj)
{
	stream<<obj.i<<",";  // stream 是 cout 的别名  
    stream<<obj.f<<",";
    stream<<obj.ch<<endl;
	return stream;
}

istream &operator>>(istream &t_stream, test &obj)
{
	t_stream>>obj.i;  // t_stream 是 cin 的别名 
    t_stream>>obj.f;
	t_stream>>obj.ch;
	return t_stream;
}

void main() {
    test A;
    operator<<(cout, "Input as i f ch:");
    operator>>(cin, A);  // 45,8.5,'W’
    operator<<(cout,A);  // 45,8.5,'W’
    
    return 0;
}
重载自增、自减运算符
  • 自增运算符:++k k++

  • 自减运算符:--k k--

按照定义,++k 返回被修改后的值,k++ 返回被修改前的值。

# include <iostream>
using namespace std;

class Demo {
    private:
        int n;
    public:
        Demo(int i=0): n(i) {}
        operator int() {return n;}
        Demo & operator++();   // 用于前置形式
        Demo operator++(int);  // 用于后置形式
};

Demo &Demo::operator++() {
    n++;
    return *this;
}

Demo Demo::operator++(int k) {
    Demo tmp(*this);  // 记录修改前的对象
    n++;
    return tmp;  // 返回修改前的对象
}

int main() {
    Demo d(10);
    // 后置形式两种写法
    cout<<(d++)<<endl;  // 10
    cout<<d<<endl;      // 11
    d.operator++(0);    // 11 不输出 有参代表后置形式
    cout<<d<<endl;      // 12

    // 前置形式两种写法     
    cout<<(++d)<<endl;   // 13 
    d.operator++();      // 14 不输出 无参代表前置形式
    cout<<d<<endl;       // 14

    return 0;
}

类的继承与派生

通过已有的类建立新类的过程,叫做类的派生。

  • 原来的类称为基类、父类、一般类
  • 新类称为派生类、子类、特殊类

派生类继承于基类,基类派生了派生类,派生类可以作为基类再次派生新的派生类,这种集合称作类继承层次结构。

使用基类派生新类时,除构造函数和析构函数外,基类的所有成员自动成为派生类的成员,包括基类的成员变量和成员函数。派生类中需要定义自己的构造函数和析构函数,可以增加基类中没有的成员,还可以重新定义或修改基类中已有的成员,包括可以改变基类中成员的访问权限。

// 基类与派生类的定义
class Base {
    int a, b;
};

class Derived: public Base {
    int c;
};

派生类占用的存储空间大小,等于基类成员变量占用存储空间大小 加上 派生类对象自身成员变量占用的存储空间大小。对象占用的存储空间包含对象中各成员变量占用的存储空间。可以使用 sizeof() 计算对象占用的字节数。

基类有友元,派生类不会继承友元类或友元函数。如果基类是某类的友元,那么这种友元关系是继承的。如果基类中的成员是静态的,在派生类中静态属性随静态成员被继承。如果基类的静态成员是公有的或者保护的,则他们被其派生类继承为派生类的静态成员。

C++ 允许从多个类派生一个类,即一个派生类可以同时有多个基类。这称为多重继承。相应地,从一个基类派生一个派生类的情况,称为单继承或单重继承。

#include <iostream>
using namespace std;

class C1 {};
class C2 {};

class C3: public C1, public C2 {
    cout<<"多重继承"<<endl;
};
  • 如果派生类中新增了同名成员,则派生类成员将隐藏所有基类的同名成员,使用派生类对象名.成员名派生类对象指针->成员名 的方式可以唯一标识和访问派生类的新增成员。这种情况下,不会产生二义性。
  • 如果派生类中没有新增同名成员,使用上面的方式访问成员时,系统无法判断到底调用哪个基类的成员,产生二义性,为避免这种情况,必须通过 基类名和作用域分辨符 来标识成员。当访问派生类对象中某个变量时,添加 基类:: 作为前缀,指明访问从哪个基类集成来的成员。

访问控制

  • public:共有继承
  • private:私有继承
  • protected:保护继承

类的共有继承

各成员 派生类中 基类与派生类外
基类的公有成员 直接访问 直接访问
基类的保护成员 直接访问 调用公有函数访问
基类的私有成员 调用公有函数访问 调用公有函数访问
从基类继承的公有成员 直接访问 直接访问
从基类继承的保护成员 直接访问 调用公有函数访问
从基类继承的私有成员 调用公有函数访问 调用公有函数访问
派生类中定义的公有成员 直接访问 直接访问
派生类中定义的保护成员 直接访问 调用公有函数访问
派生类中定义的私有成员 直接访问 调用公有函数访问

类的私有继承

第一级派生类中 第二级派生类中 基类与派生类外
基类的共有成员 直接访问 不可访问 不可访问
基类的保护成员 直接访问 不可访问 不可访问
基类的私有成员 通过公有函数访问 不可访问 不可访问

类型兼容规则

在公有派生的情况下,有以下三条兼容规则

  • 派生类的对象可以赋值给基类对象;
  • 派生类的对象可以用来初始化基类引用;
  • 派生类对象的地址可以赋值给基类指针,即派生类的指针可以赋值给基类的指针。
派生类的构造函数与析构函数

在执行一个派生类构造函数之前,总是先执行基类的构造函数。派生类对象消亡是时,先执行派生类的析构函数,在执行基类的析构函数。

#include <iostream> 
using namespace std;

//基类
class BaseClass {  
protected:
	int v1,v2; 
public:
	BaseClass(); 
    BaseClass(int,int); 
    ~BaseClass();
};

BaseClass::BaseClass() { 
    cout<<"BaseClass 无参构造函数"<<endl;
} 
BaseClass::BaseClass(int m, int n) { 
    v1=m; v2=n;
	cout<<"BaseClass 无参构造函数"<<endl;
} 
BaseClass::~BaseClass() { 
    cout<<"BaseClass 析构函数"<<endl; 
}

// 派生类
class DerivedClass:public BaseClass {
private:
    int v3;
public:
	DerivedClass(); 
    DerivedClass(int); 
    DerivedClass(int,int,int); 
    ~DerivedClass();
};

DerivedClass::DerivedClass() {
	cout<<"DerivedClass 无参构造函数"<<endl;
}
DerivedClass::DerivedClass(int k):v3(k) {
	cout<<"DerivedClass 带1个参数构造函数"<<endl;
}
DerivedClass::DerivedClass(int m, int n, int k):BaseClass(m, n), v3(k) {
	cout<<"DerivedClass 带3个参数构造函数"<<endl; 
}
DerivedClass::~DerivedClass() {
	cout<<"DerivedClass 析构函数"<<endl; 
}

int main() {
    cout<<"无参对象的创建"<<endl;
    BaseClass b;
    DerivedClass d;
    return 0;
}

/* 输出内容:
无参对象的创建
BaseClass 无参构造函数
BaseClass 无参构造函数
DerivedClass 无参构造函数
DerivedClass 析构函数
BaseClass 析构函数
BaseClass 析构函数
*/

派生类构造函数执行顺序一般次序如下:

  • 调用基类构造函数,调用顺序按照它们被继承时声明的顺序(从左向右)
  • 对派生类新增的成员变量初始化,调用顺序按照它们在类中声明的顺序
  • 执行派生类的构造函数体重的内容

在派生类构造函数执行之前,要先执行两个基类的构造函数,执行次序依据定义派生类时所列基类的次序而定。

类之间的关系

使用已有类编写新的类有两种方式:

  • 继承关系:也称为 is a 关系或 关系
  • 组合关系:也称为 has a 关系或 关系,表现为封闭类

封闭类:如果一个类的成员变量是另一个类的对象,则为封闭类。

如果基类为封闭类,函数调用顺序如下:

  • 构造函数:对象成员构造函数 - 基类构造函数 - 派生类构造函数
  • 析构函数:派生类析构函数 - 基类析构函数 - 对象成员析构函数

互包含关系的类,两个类相互引用,这种情况称为循环依赖。

多层次的派生
  • 派生类沿着类的层次自动向上继承它所有的直接和间接基类的成员,类之间的继承关系具有传递性
  • 派生类的成员包括派生类自己定义的成员、直接基类中定义的成员及所有间接基类中定义的全部成员
  • 当生成派生类的对象时,会从最顶层的基类开始逐层往下执行所有基类的构造函数,最后执行派生类自身的构造函数;当派生类对象消亡时,会先执行自身的析构函数,然后自底向上依次执行各个基类的析构函数

一个类不能被多次说明为某个派生类的直接基类,可以不止一次地称为间接基类。

基类与派生类指针的相互转换

在公有派生的情况下,因为派生类对象也是基类对象,所以派生类对象可以赋给基类对象。

对于指针类型,可以使用基类指针指向派生类对象,也可以将派生类的指针直接赋值给基类指针。但即使基类指针指向的是一个派生类的对象,也不能通过基类指针访问基类中没有而仅在派生类中定义的成员函数。

多态与虚函数

多态的基本概念

多态

多态分为:

  • 编译时多态函数的重载(包括运算符重载)。编译时根据实参确定应该调用哪个函数,编译阶段的多态称为静态多态,一个对象调用同名函数;
  • 运行时多态:和继承、虚函数等概念有关,主要指运行时多态,运行阶段的多态称为动态多态,不同对象调用同名函数。

在类直接满足 赋值兼容的前提下,实现动态绑定必须满足两个条件:

  • 必须声明虚函数
  • 通过基类类型的引用或指针调用虚函数

多态实现原理:多态的关键在于通过基类指针或引用调用一个虚函数时,编译阶段不能确定到底调用的是基类还是派生类的函数,运行时才能确定。

派生类对象占用的存储空间大小,等于基类成员变量占用的存储空间大小加上派生类对象自身成员变量占用的存储孔家你大小。

虚函数

  • 在函数声明前加了 virtual 关键字的成员函数
  • 只能在类定义中的成员函数声明处使用,类体外编写函数体时不加该关键字
  • 不能声明为虚函数的有:全局函数(非成员函数)、静态成员函数、内联函数、构造函数和友元函数
  • 不要在构造函数和析构函数中调用虚函数
  • 最好将基类的析构函数声明为虚函数
  • 包含虚函数的类称为 多态类
  • 派生类重写基类的虚函数实现多态,要求函数名、参数列表和返回值类型要完全相同
  • 基类中定义了虚函数,在派生类中该函数始终保持虚函数的特性
#include <iostream>
using namespace std;

class Base1 {
public:
    void show() { cout<<"Base1::show()"<<endl; }
};

class Base2: public Base1 {
public:
    void show() { cout<<"Base2::show()"<<endl; }
};

class Derived: public Base2 {
public:
    void show() { cout<<"Derived::show()"<<endl; }
};

// 通过基类类型的指针或引用调用
void func(Base1 *p) {
    p -> show();
}

int main() {
    // b1 是基类,b2 继承自 b1,d 继承自 b2
    // 通过子类对象赋值给基类时,调用同名方法都会变成基类的方法
    // 没有实现多态
    Base1 b1; Base2 b2; Derived d;
    func(&b1); func(&b2); func(&d);
    return 0;
}

/*
Base1::show()
Base1::show()
Base1::show()
*/

通过虚函数实现多态

class Base1 {
public:
    virtual void show() { cout<<"Base1::show()"<<endl; }
};

/*
只需要修改基类的同名方法为虚函数,派生类中自然继承
再次执行,输出结果为:实现多态
Base1::show()
Base2::show()
Derived::show()
*/

虚析构函数

如果一个基类指针指向的对象是用 new 运算符动态生成的派生类对象,那么释放该对象所占用的空间时,如果仅调用基类的析构函数,则只会完成该析构函数内的空间释放,不会涉及派生类析构函数内的空间释放,容易造成内存泄漏。

使用虚析构函数的目的是为了在对象消亡时实现多态

声明虚析构函数格式:virtual ~类名();

  • 虚析构函数没有返回值类型,没有参数
  • 如果一个类的析构函数是虚函数,那么由它派生的所有子类的析构函数都是虚析构函数
#include<iostream>
using namespace std; 

class Base {
public:
    Base() { 
        cout<<"Base::构造函数"<<endl;
    }
    // 虚析构函数,子类自动也变为虚析构函数
    virtual ~Base() {
        cout<<"Base::析构函数"<<endl;
    } 
};

class Derived: public Base {
public:
    int w,h; //两个成员
    Derived() { 
        cout<<"Derived::构造函数"<<endl;
        w=4; h=7; 
    } 
    ~Derived() { 
        cout<<"Derived::析构函数"<<endl;
    } 
};

int main() {
    Base *p = new Derived();
    //使用基类指针指向new创建的派生类对象 
    delete p;
    return 0;
}

/*
输出:
Base::构造函数
Derived::构造函数
Derived::析构函数  // 如果声明虚析构函数的话,就不会调用派生类的析构函数了
Base::析构函数
*/

纯虚函数

纯虚函数的作用相当于一个统一的接口形式,表明在基类的各派生类中应该有这样的一个操作,然后在各派生类中具体实现与本派生类相关的操作。

纯虚函数是声明在基类中的虚函数,没有具体定义。

声明格式:virtual 函数类型 函数名(参数表) = 0;

抽象类

包含纯虚函数的类称为抽象类。因为抽象类中含有未完成的函数定义,所以不能实例化一个对象。

抽象类的派生类中,如果没有给出全部纯虚函数的定义,则该派生类继续是抽象类。

虽然不能创建抽象类对象,但是可以定义抽象类的指针和引用

// 假设 Foo 为抽象类
Foo *p;

虚基类

#include <iostream> 
using namespace std; 

class A {
public: 
    int a;
    void showa() { 
        cout<<"a="<<a<<endl; 
    } 
};

class B: virtual public A {  // 对类 A 进行了虚继承 
public:
    int b;
}; 

class C: virtual public A {  //对类 A 进行了虚继承
public:  
    int c;
};

class D: public B, public C {
// 派生类 D 的两个基类 B、C 具有共同的基类 A,
// 采用了虚继承,从而使类 D 的对象中只包含着类 A 的 1 个实例
public: 
    int d;
}; 

int main() {
    D d;  // 说明派生类D的对象
    d.a = 11;  // 若不是虚继承,此行会出错!因为"D::showa"具有二义性
    d.b = 22;
    d.showa();  // 输出 11,若不是虚继承,D::showa 具有二义性
    cout<<"d.b="<<d.b<<endl; //输出Dobj.b=22
}

消除二义性。

输入/输出流

流类简介

C++中凡是数据从一个地方传输到另一个地方的操作都是的操作。

  • 读操作:被称为(从流中)“提取”
  • 写操作:被称为(向流中)“插入”

image-20230326222416127

为了避免多重继承的二义性,从 ios 派生 istreamostream 时,均使用了 virtual 关键字(虚继承)。

  • istream:提供了流的大部分输入操作,对系统预定义的所有输入流重载提取运算符 >>
  • ostream:对系统定义的所有输出流重载插入运算符 <<

iostream 类库

常见的头文件:

  • iostream:包含操作所有输入/输出流所需要的基本信息
  • iomanip:setw()、setprecision()、setfill()、setbase() 等
  • fstream:包含处理文件的有关信息,童工建立文件、读/写文件的各种操作接口

iostream

头文件 iostream 包含操作所有输入/输出流所需的基本信息,含有 4 个标准流对象:

  • cout:标准输出流,与标准输出设备(显示器)相关联,可以被重定向为向文件里写入数据;
  • cin:标准输入流,与标准输入设备(键盘)相关联,可以被重定向为从文件中读取数据;
  • cerr:输出错误信息,与标准错误信息输出设备(显示器)相关联(非缓冲),不能被重定向;
  • clog:输出错误信息,与标准错误信息输出设备(显示器)相关联(缓冲),不能被重定向。
// 将标准输出cout重定向到文件
#include <iostream>
using namespace std;
int main() {
	int x,y;
	cin>>x>>y;
	freopen("test.txt", "w", stdout); // 将标准输出重定向到文件test.txt 
    if(y==0) // 除数为0, 则输出错误信息
		cerr<<"error."<<endl; 
    else
		cout<<x<<"/"<<y<<"="<<x/y<<endl; 
    return 0;
}

函数 freopen() 的功能是将 stream 按 mode 指定的模式重定向到路径 path 指向的文件。

iomanip

C++ 进行 I/O 格式控制的方式一般有使用流操纵符、设置标志字和调用成员函数。

流操纵符

不带参数的流操纵符:

  • endl(O):换行符,输入一个换行符,清空流
  • ends(O):输出字符串结束,清空流
  • flush(O):清空流缓冲区
  • dec(I/O,默认):十进制形式
  • hex(I/O):十六进制形式
  • oct(I/O):八进制形式
  • ws(O):提取空白字符

包含格式化 I/O 的带参数流操纵符,可用于指定数据输入/输出的格式。例如:

  • setw(int w):指定输出宽度为 w 个字符,或输入字符串时读入 w 个字符,一次有效
  • setprecision():设置有效数字位数,全部数字个数
  • setfill():指定输出宽度,宽度不足时用空格填充
  • setbase():输入表示数值进制的前缀
  • setiosflags():设置标志字

进制标识:

  • 十六进制常量——前缀0x,09、af
  • 十进制常量——无前后缀,0~9
  • 八进制常量——前缀0,0~7;080就是非法数
  • 长整型常量——后缀L或l
标志字
#include<iostream> 
#include<iomanip> 
using namespace std; 

int main() {
	double x=12.34; 
    cout<<"1)"<<setiosflags(ios::scientific|ios::showpos)<<x<<endl; 
    // ios::scientific 科学计数法 showpos 正数前加 “+” 号
    // 输出:+1.234000e+001
    
    cout<<"2)"<<setiosflags(ios::fixed)<<x<<endl; 
    // fixed 定点形式表示浮点数
    // +12.34
    
    cout<<"3)"<<resetiosflags(ios::fixed)<<setiosflags(ios::scientific|ios::showpos)<<x<<endl; 
    // +1.2340003+001
    
    cout<<"4)"<<resetiosflags(ios::showpos)<<x<<endl;
    // 清除要输出正号的标志 
    // 1.234000e+001
    
    return 0;
}

调用 cout 的成员函数

成员函数 作用相同的流操纵符
precision(int np) setprecision(np)
width(int nw) setw(nw)
fill(char cFill) setfill(cFill)
setf(long iFlags) setiosflags(iFlags)
unsetf(long iFlags) resetiosflags(iFIags)
cout.put('d');  // cout<<'d',向输入流中插入一个字符
cout.write();  // 向输出流汇总插入 数据块

调用 cin 的成员函数

// get() 函数
while((ch=cin.get()) != EOF) //当文件没有结束时继续进行循环 
{
	cout.put(ch);
} 

// getline() 函数,从输入流中读取一行字符
// 函数原型
istream & getline(char * buf, int bufSize);  // 读取 size-1 个字符到缓冲区 或到 \n 截止
istream & getline(char * buf, int bufSize, char delim);  // 或到 delim 截止

// eof() 函数,用于判断输入流是否已经结束,返回 true 表示输入结束
// 测试是否到文件尾,到文件返回 1,否则返回 0
bool eof( );

// ignore() 函数,跳过输入流中的 n 个字符 或 delim 及其之前的所有字符
// cin.ignore() == cin.ignore(1, EOF) 默认值,即跳过一个字符
istream & ignore(int n=1, int delim=EOF);

// peek() 函数,返回输入流中的当前字符,只看一眼
// 输入流已经结束的情况下,cin.peek() 返回 EOF
int peek( );

文件和操作

文件基本概念和文件流类

根据文件数据的编码方式不同分为:

  • 文本文件

  • 二进制文件

根据存取方式不同分为:

  • 顺序存取文件:按照文件中数据存储次序进行顺序操作,访问第 i 个数据,首先得访问 i-1
  • 随机存取文件:根据应用需要,通过命令移动位置指针直接定位到文件位置

对文件的基本操作分为:

  • 读文件:将文件中的数据读入内存之中,也称为输入
  • 写文件:将内存中的数据存入文件之中,也称为输出

C++ 标准类库中有 3 个流类可以用于文件操作,统称为文件流类,分别如下:

  • ifstream:用于从文件中读取数据
  • ofstream:用于向文件中写入数据
  • fstream:即可用于读取数据,也可用于写入数据

使用这 3 个流类,需要包含 fstream 头文件。

在程序中,要使用一个文件,必须包含3个基本步骤:

  • 打开(open)文件
  • 操作文件:对文件进行读/写
  • 关闭(close)文件

C++ 文件流类有相应的成员函数来实现打开、读、写、关闭等文件操作。

打开和关闭文件

打开文件的方式有以下两种:

  1. 先建立流对象,然后调用 open() 函数连接外部文件。格式如下:
    流类名 对象名;
    对象名.open(文件名, 模式);
  2. 调用流类带参数的构造函数,建立流对象同时连接外部文件,格式如下:
    流类名 对象名(文件名,模式);
// 输入:方式一
ifstream inFile; //建立输入文件流对象
inFile.open("data.txt", ios::in); //连接文件,指定打开模式,默认为 in
// 输入:方式二
ifstream inFile("data.txt", ios::in);

// 输出:方式一
ofstream outFile; //建立输入文件流对象
outFile.open("c:\\c2019\\newfile",ios::out | ios::binary);  // 二进制文件
// 输出:方式二
ofstream outFile("c:\\c2019\\newfile",ios::out | ios::binary);

使用 fstream 中的成员函数 close() 关闭文件。

inFile.close();
outFile.close();

文件读写操作

读写文本文件
/*
	键盘输入学生的学号、姓名和成绩,存入 score.txt 中
	每行保存一名学生的成绩信息,各项数据之间用空格分隔
*/
#include <iostream>
#include <fstream>
using namespace std;

int main() {
    char id[11], name[21];
    int score;
    
    // 写文件
    ofstream outFile;
    outFile.open("score.txt", ios::out);  // 写方式打开文件
    if (!outFile) {
        cout<<"创建文件失败!"<<endl;
        return 0;
    }
    cout<<"请输入:学号 姓名 成绩(以 Ctrl+Z 结束!)";
    while(cin>>id>>name>>score)
		outFile<<id<<" "<<name<<" "<<score<<endl; //向流中插入数据 
    outFile.close();
    
    // 读文件
    iftream inFile("score.txt", ios:in);  // 读方式打开文件
    if (!inFile) {
        cout<<"打开文件失败!"<<endl;
        return 0;
    }
    cout<<"学生学号 姓名\t\t\t成绩\n"; 
    while(inFile>>id>>name>>score)  // 读入文件
		cout<<left<<setw(10)<<id<<" ";
    	cout<<setw(20)<<name<<" ";
        cout<<setw(3)<<right<<score<<endl;
    inFile.close( );
	return 0;
}
读写二进制文件

需要用 binary 方式打开二进制文件。

// 用 ostream::write() 成员函数写文件
ostream & write(char * buffer, int nCount);
// e.g.
ofstream outFile("students.dat",ios::out|ios::binary); 
outFile.write((char*)&stu, sizeof(stu)); 

// 用 istream::read() 成员函数读文件
istream &read(char * buffer, int nCount);

// 用 ostream::gcount() 成员函数得到读取字节数
int gcount();
用成员函数 put() 和 get() 读写文件
// 不带参数,提取一个字符并返回,当遇到文件结束符,返回 EOF
int get();

// 从指定输入流中提取一个字符
istream& get(char &rch);

// 从流的当前字符开始,读取 nCount-1 个字符,到 delim 结束
istream& get(char *pch, int nCount, char delim=’\n’);

// put 向输出流中插入一个字节
ostream& put(char ch);
文本文件与二进制文件异同

在输入/输出过程中,系统要对内外存的数据格式进行相应转换。

  • 文本文件:以文本形式存储数据

    • 优点:具有较高的兼容性
    • 缺点:1.存储一批纯数值信息时,要人为地添加分隔符;2.不便于对数据进行随机访问。
  • 二进制文件:以二进制形式存储数据

    • 优点:便于对数据实行随机访问 (相同数据类型的数据所占空间的大小均是相同的,不必在数据之间人为地添加分隔符),在输入/输出过程中,系统不需要对数据进行任何转换。
    • 缺点:数据兼容性差

通常纯文本信息(如字符串)以文本文件形式存储,而将数值信息以二进制文件形式存储。

随机访问文件

顺序文件:如果一个文件只能进行顺序存取操作,则称为顺序文件。

  • 典型的顺序文件 (设备)是键盘、显示器和保存在磁带上的文件
  • 在访问文件的过程中,若严格按照数据保存的次序从头到尾访问文件,则称为顺序访问
  • 只能进行顺序访问

随机文件:如果一个文件可以在文件的任意位置进行存取操作,则称为随机文件。

  • 磁盘文件就是典型的随机文件

  • 在访问文件的过程中,若不必按照数据的存储次序访问文件,而是要根据需要在文件的不同位置进行访问,则称为随机访问

  • 既可以进行顺序访问,也可以进行随机访问

**类 istream **中与位置指针相关的函数如下:

/* 1. 移动读指针函数 */
// 该函数的功能是将读指针设置为 pos,即将读指针移动到文件的 pos 字节处
istream & seekg(long pos);

// 将读指针按照 seek_dir 的指示(方向)移动offset个字节
// 其中seek_dir 是在类ios中定义的一个枚举类型 
enum seek_dir {beg=0, cur, end};
// ios::beg 流的开始位置。此时,offset 应为非负整数
// ios::cur 表示流的当前位置。offset 为正数则表示向后(文件尾)移动,为负数则表示向前(文件头)移动。
// ios::end 表示流的结束位置。此时 offset 应为非正整数
istream & seekg(long offset, ios::seek_dir dir);

/* 2. 返回写指针当前位置的函数 */
// 函数返回值为流中读指针的当前位置。
long tellg(); 

**类 ostream **中与位置指针相关的函数如下:

/* 1. 移动写指针函数 */
// 该函数的功能是将写指针设置为 pos,即将写指针移动到文件的 pos 字节处
ostream & seekp(long pos);
// 该函数的功能是将写指针按 seek_dir 指示的方向移动 offset 个字节
ostream & seekp(long offset, ios::seek_dir dir);

/* 2. 返回写指针当前位置的函数 */
// 函数的返回值为流中写指针的当前位置
long tellp();

函数模板与类模板

函数模板

设计程序中的函数时,可能会遇到函数中参数的类型有差异,但需要实现的功能类似的情形。函数重载可以处理这种情形。重载函数的参数表中,可以写不同类型的参数,从而可以处理不同的情形。

为了提高效率,实现代码复用,C++ 提供了一种处理机制,即使用函数模板。函数在设计时并不使用实际的类型,而是使用虚拟的类型参数。

当用实际的类型来实例化这种函数时,将函数模板与某个具体数据类型连用。编译器将以函数模板为样板,生成一个函数,即产生了模板函数,这个过程称为函数模板实例化。函数模板实例化的过程由编译器完成。程序设计时并不给出相应数据的类型,编译时,由编译器根据实际的类型进行实例化。

#include <iostream>
using namespace std; 

template<typename T> 
T abs(T x) {
	return x<0?-x:x; 
}

int main() {
	int n=-5;
	int m=10;
	double d=-.5;
	float f=3.2; 
    cout<<n<<"的绝对值是:"<<abs(n)<<endl; 
    cout<<m<<"的绝对值是:"<<abs(m)<<endl; 
    cout<<d<<"的绝对值是:"<<abs(d)<<endl; 
    cout<<f<<"的绝对值是:"<<abs(f )<<endl; 
    return 0;
}

函数与函数模板也是允许重载的。在函数和函数模板名字相同的情况下,一条函数调用语句到底应该被匹配成对哪个函数或哪个模板的调用呢?

C++ 编译器遵循以下先后顺序:

  1. 先找参数完全匹配的普通函数(不是由模板实例化得到的模板函数);
  2. 再找参数完全匹配的模板函数;
  3. 然后找实参经过自动类型转换后能够匹配的普通函数;
  4. 如果上面的都找不到,则报错。

类模板

通过类模板,可以实例化一个个的类。

  • 继承机制也是在一系列的类之间建立某种联系,类是相同类型事物的抽象,有继承关系的类可以具有不同的操作。
  • 模板是不同类型的事物具有相同的操作,实例化后的类之间没有联系,相互独立。

不能使用类模板来直接生成对象,因为类型参数是不确定的,必须先为模板参数指定“实参”,即模板要“实例化”后,才可以创建对象。也就是说,当使用类模板创建对象时,要随类模板名给出对应于类型形参或普通形参的具体实参。

格式如下:

  • 类模板名 <模板参数表> 对象名1,...,对象名n;

  • 类模板名 <模板参数表> 对象名1(构造函数实参),...,对象名构造函数实参);

类模板中的成员函数全部都是模板函数

#include<iostream>
using namespace std; 

template<class T> 
class TestClass {
public:
	T buffer[10];
	T getData(int j); 
};

template<class T>
T TestClass<T>::getData(int j) {
    return *(buffer+j);
};

int main() {
    // char 取代 T,从而实例化为一个具体的类
    TestClass<char> ClassInstA;  
    int i; 
    char cArr[6]="abcde"; 
    for(i=0; i<5; i++) {
        ClassInstA.buffer[i]=cArr[i];
    }
    for(i=0; i<5; i++) {
        char res = ClassInstA.getData(i);
        cout<<res<<" ";
    }
    cout<<endl;
    
    // 实例化为另外一个具体的类 double
    TestClass<double> ClassInstF; 
    fArr[6]={12.1,23.2,34.3,45.4,56.5,67.6}; 
    for(i=0; i<6; i++) {
        ClassInstF.buffer[i]=fArr[i]-10;
    }
    for(i=0; i<6; i++) {
        double res = ClassInstF.getData(i);
        cout<<res<<" ";
    }
    cout<<endl;
    
	return 0; 
}

类之间允许继承,类模板之间也允许继承。具体来说,类模板和类模板之间、类模板和类之间可以互相继承,它们之间的常见派生关系有以下4种情况:

  1. 普通类继承模板类
  2. 类模板继承普通类
  3. 类模板继承类模板
  4. 类模板继承模板类。

根据类模板实例化的类即是模板类。

#include <iostream> 
using namespace std; 

template<class T> class TBase {  // 类模板,基类
	T data;
public:
	void print() { cout<<data<<endl; } 
};

class Derived:public TBase<int> {};  //从模板继承,普通类

int main() {
	Derived d; // 普通派生类的对象 
    d.print(); // 调用类模板中的成员函数
    return 0;
}
posted @ 2023-03-08 14:43  ABEELAN  阅读(202)  评论(0编辑  收藏  举报