Cpp_复习整理_1_类与对象

写在开头


大一终于结束了,经过了一年的C++学习,是时候对所学知识进行整理与总结了。

其实老早就可以总结了,但是我懒癌,并且这个博客前天才刚建好,故这个系列只对大一下所学的“面向对象编程”部分进行整理与总结。


面向过程编程 VS 面向对象编程

面向过程编程

是一种传统的设计方法,围绕着功能进行编程。

程序=数据结构+算法

并且往往具有以下的特征

  • 用一个函数实现一个功能
  • 所有的数据都是公用的
  • 一个函数可使用任何一组数据
  • 一组数据可被多个函数使用

但是这样结构化的程序设计,在程序规模增大的过程中,渐渐显示出不足。

软件业的目标是更快、更正确、更经济地建立软件。其中,就需要实现两个目标:

  1. 如何更高效的实现函数的复用?
  2. 如何更清晰的实现变量和函数的关系?使得程序更清晰更易于修改和维护。

而结构化程序设计中,程序的大量函数、变量之间的关系错综复杂,要抽取部分代码来实现复用,会变得十分困难。

面向对象编程

因此,面向对象的程序设计方法,应运而生。

面向对象的程序=类+类+类+类+…+类

而设计程序的过程,就是设计类的过程。

是围绕着现实世界的实体(对象)进行设计;程序设计者从“设计函数功能”转向“设计类和对象”,即如何用属性和行为来描述一个实体,如何向实体发送消息以调度实体的行为。这是一种以认识世界的方法为参考的程序设计方法,更为自然,有利于大型程序的组织和实现。


类与对象的实现

如何声明一个类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class rectangle{
public:
rectangle(double l=1,double w=1){
len=l;
wid=w;
}
~rectangle(){}
double setData(double l,double w){
len=l;
wid=w;
}
double area(){
return len*wid;
}
double perimeter(){
return 2*(len+wid);
}
private:
double len;
double wid;
};

如上,我们声明了一个矩形类,其中包含了public的成员函数:构造函数析构函数设置长宽函数setData,面积函数area,以及周长函数perimeter,以及private的数据成员:lenwid

类中可以设置变量作为数据成员,也可以设置函数作为成员函数(同时也被称作方法)。

  • 注意:类本身的类型不能作为数据成员的类型!但是指向类本身的指针可以。

    1
    2
    3
    4
    5
    6
    class obj{
    public:
    obj childObj;//不合法

    obj* nextObj;//合法
    };

成员访问限定符

类中的成员可以设置成员访问限定符,即publicprotectedprivate

如果一个成员没有给出成员访问限定符,则默认为private

  • public公有的:权限最开放,在类的外部,用户也可以通过成员运算符(.->)来访问该数据成员和成员函数。
  • private私有的:在类的外部,用户不能通过成员运算法(.->)来访问该数据成员和成员函数。只有类的成员函数友元函数才能访问。
  • protected受保护的:如果不进行类的派生和继承,其访问特性与private是完全相同的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class obj{
public:
void fun1();
friend void fun2(obj);
int data1;
private:
void fun3();
int data2;
protected:
void fun4();
int data3;
};

void obj::fun1(){ //均合法,fun1为成员函数,为类内访问
int tmp=data3;
tmp=data2;
fun3();
fun4();
}

void fun2(obj myObj){ //均合法,fun2为友元函数,可以访问private和protected的成员
int tmp=myObj.data3;
tmp=myObj.data2;
myObj.fun3();
myObj.fun4();
}

int main(){
obj myObj1;
obj* myObj2=new obj;
int tmp;
tmp=myObj1.data1;//合法
tmp=myObj1.data2;//不合法,因为data2是私有成员,不能通过成员运算符在类外访问。
tmp=myObj2->data3;//不合法,此处protected和private作用一样
myObj2->fun1();//合法,且->运算符用于指针的成员运算。
myObj1.fun3();//不合法,fun3()为私有成员
myObj1.fun4();//不合法,受保护成员也不能从外部访问。
delete myObj2;
return 0;
}

常成员

常数据成员

const进行修饰的数据成员称为常数据成员

1
2
3
//有两种声明形式
const int x;
int const x;
  1. 任何函数都不能对const数据成员赋值。
  2. 构造函数对const数据成员进行初始化时只能通过参数初始化列表进行。
  3. const数据成员的值不能改变,所以不能对其进行赋值,只能对其进行初始化。因此const数据成员在初始化时必须进行赋值
  4. 如果类中有多个构造函数,则每个构造函数必须都要初始化const数据成员。

常成员函数

1
2
//声明
<类型标识符> 函数名(参数表)const;
  1. const作为函数的一部分,可以作为函数重载的区分,因此在声明定义时都必须要有const关键字。
  2. 而在函数调用时,const不一定是必须的。若不是同时具有**两个同类型同名同参的函数,区别仅仅const关键字,这时候函数调用时const就不是必须的。
  3. 常成员函数可以引用const或者非const数据成员,但是不能“修改”它们。即:常成员函数不能更新任何数据成员。因此,常成员函数多用于数据的输出等操作。
  4. const成员函数不能调用非const成员函数,而非const成员函数可以调用const成员函数。

静态成员

静态数据成员

是一种特殊的数据成员,以关键字static开头。

1
2
3
4
class student{
static int data;//只是变量声明
const static int data1;
}stu;
  1. 属于类,是类的派生,不属于任何一个单独的对象,由类的所有对象共享的数据,不占用具体实例的空间

  2. 只能类外初始化,如果未赋初值,则默认为0。不能用参数初始化列表进行初始化。

    1
    2
    int student::data=114514;//不加static
    const int student::data1=1919810;
  3. static数据成员在程序开始运行时被分配空间,到程序结束时才释放空间——也就是它有着全局寿命

  4. 静态数据成员的使用:可以通过类名引用,如student::data;也可以通过对象名引用,如stu.data1。建议使用类名来引用静态成员,代码更清晰,通过对象名的引用也仅仅是使用了该对象的“类型”

  5. 静态数据成员相当于类域的全局变量

  6. 静态数据成员可以成为成员函数的参数,而普通成员变量不可以。

    1
    2
    3
    4
    5
    6
    class student{
    int data1;
    static int data2;
    void fun1(int data1);//ERROR
    void fun2(int data2);//OK,静态数据成员相当于确定值
    };

静态成员函数

1
2
3
4
class obj{
public:
static void fun();
}myObj;

和静态数据成员一样,是类的一部分,而不是对象的一部分。

调用时使用类名或者对象名来访问。

1
2
obj::fun();
myObj::fun();
  1. 静态成员函数不隐含this指针,故不能访问非静态成员,通常只用于访问静态成员。但非静态成员函数可以调用静态成员函数。

    而普通成员函数具有this指针,访问类内的成员时,直接写数据成员名或成员函数名,前方自动略写了(*this).这种访问称为默认访问

  2. 不能同时用staticvirtual两个关键字修饰一个成员函数,即:虚函数不能是static函数。因为虚函数必须要通过对象调用,必须要有隐藏的this指针。并且static成员函数是在编译时静态决议的,而virtual成员函数是动态决议的(运行时才绑定)。

友元函数

友元类

如果将类B声明为类A的友元类,那么友元类B中的所有函数都是类A的友元函数,可以访问类A中的所有成员。

1
2
3
4
5
6
class B{...};
class A{
friend B;
//或者写成 friend class B;
...
}

注意:通常不把整个类声明成友元,只把有需要的成员函数声明成友元,这样更安全。

友元函数

友元函数不是一个类的成员函数。通常的习惯是类内声明+类外定义。友元函数是独立于任何类的一般的外界函数,所以它没有this指针,即不支持默认访问

1
2
3
4
class obj{
friend void fun();
};
void fun(){...} //类外定义时不加friend

友元成员

注意与友元函数的区别。

一个类的成员函数可以声明为另一个类的友元,这类成员被称为友元成员

1
friend 类名::成员函数名();

友元函数和友元成员可统称为“友元函数”,可以访问类的private成员。

友元的注意点

  1. 友元不具有传递性:

    A是B的朋友,而B是C的朋友,但是A不是C的朋友

  2. 友元关系是单向的,不具有交换性。

    A声明B是自己的朋友,但是B并不是A的朋友,因为B没有声明A是自己的朋友。

    1
    2
    3
    4
    5
    class A{
    friend B;
    ...
    };
    class B{...};

    B可以访问A的私有成员,但A不能访问B的私有成员。

  3. 友元关系不受成员访问限定符的影响,友元的声明可以出现在类中的任何地方。

成员函数的类内与类外定义

类内定义

如上一段代码,函数定义就属于类内定义。直接将函数体写在类声明内部。

内联函数-inline关键字

可以在函数首部的前方添加inline关键字,使其成为内联函数,其效果为,在调用到该函数的地方直接将函数体内的代码展开,节省了调用的过程所花费的时间

  • inline说明对于编译器只是一个建议,编译器可以选择忽略这个建议。例如将一个长达1000行的函数声明为内联函数,编译器会无视这个inline的建议,仍旧将这个函数设置为普通函数。
  • 在类内直接定义的函数,不需要inline修饰,编译器也一般会将其化为内联函数
慎用内联函数
  1. 内联函数在每次调用函数的位置复制函数的代码。如果执行函数体内代码的时间,相比于函数调用的开销较大,那么效率的收获会很少。所以一般会选择频繁调用代码量较少的函数设置为内联函数
  2. 每一处内联函数的调用都会复制代码,会增加程序的代码量,增加内存开销。
  3. 函数中若带有循环,则不宜选用内联函数。函数本身运行时间就比调用时间长,已经没有必要使用内联函数
类外定义
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class rectangle{
public:
rectangle(double=1,double=1);//tips:函数声明时,形参类型必须写,但是形参名可以不用写。
~rectangle(); // 默认值最好写在声明中,这是一个好习惯。
double setData(double,double);
double area();
double perimeter();
private:
double len;
double wid;
};

rectangle::rectangle(double l=1,double w=1){
len=l;wid=w;
}
...
double rectangle::area(){
return len*wid;
}
...

类外定义需要现在类内写出函数声明,然后在类外通过返回值类型 类域分辨名::函数名(形参列表)来进行函数定义

  • 建议:函数定义都应该在类外进行,并置于cpp文件内;而声明均在类内,并置于头文件内。这是一个好习惯。

怎样使用一个类和对象

定义一个对象

1
2
rectangle rec1;
rectangle rec2(2,2);

如此,便定义了rec1rec2这两个对象。其中rec1使用的是构造函数的默认参数,一个长一宽一的矩形,而rec2是一个长二宽二的矩形。

常对象

定义对象时指定对象为常量,其数据成员的值在对象被调用时不能被改变。

1
2
3
//声明,const在类型前后均可
rectangle const rec1;
const rectangle rec2(2,2);
  1. 常对象必须在定义时初始化,并且不能被更新。

  2. 为了防止非const成员函数修改常对象中的数据成员的值,**常对象只能调用const成员函数,以及构造函数析构函数

    • 为何如此决绝?

      因为程序设计时,函数声明函数定义大概率不会存在于同一个源文件中,而程序编译时是以源文件为单位的,因此,系统无法检测出函数声明函数定义之间是否存在矛盾(例子:函数声明了不作修改,但是函数定义中却对某个数据成员进行了“不小心“的修改),只有等到了连接(link)甚至是运行阶段才能发现,因此编译系统值检查函数声明,只要发现调用了非const的成员函数,就直接报错,即使这个函数并不会对任何数据成员进行修改

  3. 非const对象可以调用const成员函数。

  4. 常对象中可以存在非const成员函数,但是无法被该对象调用。而常对象中的所有数据成员均具有常数据成员的特征。

构造函数

  1. 构造函数是每一个类都必须会有的函数。
  2. 构造函数是一种特殊的成员函数,无需用户调用,在定义新对象时由程序自动调用执行。
  3. 它的名称和类名一致,且没有函数类型void也不行;因此,构造函数也没有返回值
  4. 构造函数可以重载
  5. 构造函数的分类
    • 无参构造函数
    • 含参、带默认值的构造函数
    • 复制构造函数(拷贝构造函数)

无参构造函数

1
rectangle(){}

如此,便是一个最简单的构造函数,它没有任何参数,也没有任何实质性的作用。

  • 重要:如果用户没有自定义任何类型的构造函数,则编译器会提供缺省的无参构造函数,即上方的构造函数,没有任何形式的参数,并且函数体为空。因此,常常显式地给出一个public的无参构造函数,以便定义对象时被系统自动调用。

    tips:即使用户定义的是含参的构造函数或是拷贝构造函数,该缺省的无参构造函数也不会提供。

含参构造函数

1
rectangle(double l,double w=1){len=l;wid=w;}

这是一个带有两个参数,且其中一个带有默认值的双参构造函数

在定义对象时,在对象名后紧跟实参来调用。

1
2
rectangle rec1(5,7);
rectangle rec2(5); //第二个参数使用的是默认参数

参数初始化列表

对于构造函数来进行对象的初始化,还可以采用参数初始化列表的形式进行,写法为:

1
rectangle(double l,double w=1):len(l),wid(w){}

在原函数首部的括号后面加上一个:,采用变量名(值)的形式完成对成员的初始化赋值。

单参构造函数的特殊用法

1
2
3
rectangle(double l){...}

rectangle(double l,double w=1){...}

注意:这两种写法写在一起会产生二义性错误,如果调用时只给一个实参,系统将无法确定该调用哪一个构造函数。

这两个构造函数的共同特点是: 有且只有一个无默认值的形参

在某些特定的情况下可以起到类型转换的作用。

比如:

1
rectangle rec1=5;

此处等号右侧,int整数首先通过单参构造函数rectangle(5),生成一个临时的无名对象,再通过拷贝构造函数来构造rec1。并且构造完成之后,这个临时无名对象即刻被析构

拷贝构造函数

1
rectangle(const rectangle& obj){...}

拷贝构造函数也是缺省构造函数的一种重载,其参数列表为同类对象的一个常引用,当用户没有给定拷贝构造函数的时候,编译器会加上一个缺省的拷贝构造函数

浅拷贝与深拷贝

缺省的拷贝构造函数作用是将两个对象的数据成员进行一一复制的赋值,即简单的对拷,是“浅拷贝”构造函数。

在不涉及动态分配的空间时,这种浅拷贝大多时候不会有问题。

但是,如果类中包括了动态分配的空间,且在析构时对申请的空间进行了释放,则通过浅拷贝构造的对象,就可能导致析构函数多次释放堆内存的问题。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
class obj{
public:
obj(int v){p = new int(v);}
obj(const obj& t){p=t.v;} //系统默认的拷贝构造函数,实际上不会写出来
~obj(){delete p;}
private:
int *p;
};
int main(){
obj a(5);
obj b(a);
return 0;
}

生成的ab两个对象中*p的值均为5,且两者均指向同一个内存空间,也就是说二者的5是同一个5。

  • 这时当main函数结束时,系统就会报错,因为在析构a时,p中的地址所指向的空间已经在析构b时被释放了,这时再释放这个地址所指的空间,也就是多次释放内存的问题。

所以为了避免这个问题,需要我们自己显式定义拷贝构造函数,而不是使用缺省的拷贝构造函数。上面例子的拷贝构造函数可以给出:

1
2
3
4
obj(const obj& t){
p=new int;
*p=t.(*p);
}
  • tips:此处提到析构顺序是先ba,看上去不合常理,但这确实是这样,程序存储变量的位置属于的结构,也就是LIFO表,即后进先出表,先构造的后析构,ab先构造,所以ba先析构

何时会调用拷贝构造函数

  1. 用类的一个对象去“初始化”另一个对象时。

    1
    2
    3
    4
    5
    6
    //形式1
    Cat cat1;
    Cat cat2(cat1);
    //形式2
    Cat cat1;
    Cat cat2 = cat1;
  2. 对象作为函数参数传递,按照传值调用的方式传递给另一个对象时,生成对象副本。

    1
    2
    3
    4
    5
    6
    7
    void fun(Box b){...}

    int main(){
    Box box1(12,15,18);
    fun(box1);
    return 0;
    }

    fun函数的实参为box1,形参为b,且调用方式为传值调用,参数传递时会调用拷贝构造函数,创建出box1的对象副本给b,参数传递的效果等同于Box b=box1或者Box b(box1)

  3. 函数返回值为类对象,当函数调用返回时,系统会调用拷贝构造函数生成一个临时无名对象,返回到主调函数处。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    Box fun(){
    Box box1(12,15,18);
    return box1;
    }

    int main(){
    Box box2;
    box2 = fun();
    return 0;
    }

    调用fun函数返回时,系统生成box1的副本,是一个临时无名对象,完成给box2的赋值之后即刻消亡。

    为什么要使用常引用的参数?

    1. 使用引用作为形参的好处:

      不会调用拷贝构造函数创建局部变量,相应的,也不用在函数调用结束时去调用析构函数来消亡这个局部变量,节省了函数调用的时间,提高了运行效率。

    2. 添加const,使用常引用的好处:

      防止函数中对实参进行修改。

析构函数

1
~rectangle(){}

这是一个析构函数,并且,如果用户没有自定义析构函数,那么系统会添加缺省的析构函数,也就是这个样子——函数体中没有任何代码

  1. 析构函数的名称和构造函数类似,也是与类名一致,区别是前面加上了一个表示取反的~,用来表示其与构造函数相反的作用——实现对象的撤销
  2. 析构函数没有函数类型,void也不行,因此也没有返回值
  3. 析构函数不能有任何参数,因此,它也不能重载(reload),一个类只能有一个析构函数;但是,后续类的继承中,它可以被覆盖(override)
  4. 执行完析构函数,并不意味着对象已经消亡,析构函数的作用是对需要撤销的对象进行清理,以防止出现资源泄漏的问题。而对于对象所占的空间的撤销,并不是由析构函数来完成的。(由delete清除new运算符定义的对象时,先调用析构函数,然后再收回分配的内存。)
  5. 如果类中的数据成员为指针,且关联动态分配的空间时,必须手工添加析构函数

什么时候调用析构函数

  1. delete运算符导致析构函数调用。
  2. 编译器产生的临时对象不再需要时。
  3. 程序运行结束时。

简言之就是:一个对象消亡时(生命周期结束),会调用析构函数

赋值运算符的重载函数

在类定义时,系统会给出四个缺省的函数。它们是无参构造函数拷贝构造函数析构函数。而剩下的一个,就是赋值运算符的重载函数

缺省的赋值运算符重载函数形式为:

1
2
3
4
5
类名& operator= (const 类名& right){
(*this).成员=right.成员;
...
return *this;
}

实现的是成员的简单对拷,属于浅拷贝

那么前面也说过,最好是实现深拷贝,所以需要手写赋值运算符的重载函数。

本篇结束

简单总结了一下类的构成,本篇代码均在Typora中手打,没有经过编译器的语法检查,可能存在谬误。如有发现请指出。


Cpp_复习整理_1_类与对象
https://bao-gai-yu.github.io/2022/07/14/Cpp-复习整理-1/
作者
宝盖于-BaoGaiYu
发布于
2022年7月14日
许可协议