面向对象程序设计课程笔记

这是本人为了应付学校oop考试(结果还是喜提3.7),以课程ppt为基础整合出来给自己复习的文章。

面向对象编程

面向对象的三要素

  • 封装与数据屏蔽

  • 继承与重用

  • 多态

面向对象编程的过程

  • OOA:面向对象分析(干什么)

  • OOD:面向对象设计(怎样干)

  • OOP:面向对象编程(实现)

面向对象的原则

原则解释
单一职责一个大类不如多个小类灵活
接口隔离依赖关系建立在最少的接口上
开放封闭对扩展开放、对修改关闭
依赖倒转最好关联抽象类,越抽象越底层
里氏替换子类对象可以完全顶替父类,而程序的行为没有变
迪米特一个对象应该对其他对象有最少的了解
合成聚合复用尽量使用合成(成员与对象存活时间相同,例如有成员Engine _engine)/聚合(成员与对象存活时间不同,例如有成员Engine* _engine),尽量不使用继承

指针

数组

a[b]等效于*(a+b),数组名就是指针。

类似于int (*p)[4]这样的写法中,p指的是一个指向元素数量为4int型数组的指针。

指针函数

指针函数用来申请空间或者查找数据,函数指针用来动态指定参与运算的函数。

  • 定义指针函数:int* f(int x)

    int*是返回类型,f是函数的名字。
  • 定义函数指针:int (*g)(int)

    int是返回类型,g是函数指针的名字。
    函数指针的使用更像是一个变量,保存着函数,定义在main()或者其他的函数里面。

如果要用typedef来给函数指针起别名,可以参考:

1
typedef int (*the_func_pointer_name)(int,int);

调用the_func_pointer_name关键字就可以定义一个参数为两个int,返回值为int的函数指针。

给函数指针赋值可以用函数名&函数名

1
2
3
4
5
6
int f(int a,int b);
int g(int a,int b);
typedef int (*the_func_pointer_name)(int,int);
the_func_pointer_name a,b;
a=f;
b=&g;

特别地,int (*F(int x))(int,int)是一个函数,其返回值是一个函数指针int (*F)(int,int),这个函数指针对应的函数有两个int类型的参数,调用形参int x之后返回。可以参考typedef在函数指针中的用法。

常量

  • 指针常量int* const pp指向的地址不可以被改变,数组头就是类似的。
  • 常量指针const int* pint const *pp指向的地址可以改变,但是*p是一个常量。

    常量const int类型的变量,其地址不能被赋给int*类型的指针,而必须是赋给常量指针const int*,否则会使权限被外界扩大。
  • 指向常量的常量指针const int *const p:既不能修改地址,也不能修改指向的量。
定义意义
const int *const p指向常量的指针常量
int const * const p指向常量的指针常量
int * const p指针常量
int const * p常量指针

定义const指针的意义:

  • 内部需要this和常成员函数的this
  • 参数传递:传递过程中权限被缩小的机制,使数据得到保护,责任划分清楚

例如:

定义意义备注
void A(int const *const p);指向常量的指针常量
void B(int *const p);指针常量数组内容可能被修改
void C(int const *p);常量指针
void D(int *p);普通指针数组内容可能被修改

const还有一个重要用法:用于常成员函数

引用

普通的函数传参存在形式歧义。

如果有一个函数,它的参数是一个类A,那么参数传递相当于:class A swap.a = main.a,就是在这个函数的作用域里面重新创建了一个这样的对象,并将其赋值为传入的参数,会引起拷贝构造函数或赋值运算的调用,如果这个过程很复杂,代价就会非常大。如果Class A继承或者组合了其他类。则要引起拷贝构造函数的层层调用,代价更大。使用引用则可以改善。

引用就是别名。定义必须初始化:int& i=j;。因为如果int& i=j;,那么i==j&i==&j。引用一旦被定义了,就不能再被修改,它和指针的意义是等价的,只不过更直白,所以是使用&运算符定义。

与指针的关系

  • 引用可以代替指针常量:
    • int i;int *const p=&i;

      等效于:
    • int &p=i;
  • 引用数组int a[10]时,可以采用:
    • int (&f)[10]=a;
  • 没有引用的引用。
  • 不存在void类型的引用。
  • 引用与const
    • 引用一般不能强制类型转换,例如int a=1;时,float &b=a;float &c=float(a);float &d=(float)a;是不被允许的,但是如果将它定义为float const &e=a;则是可以的。
    • int &i=1;是错误的,但是int const &i=1;正确

函数返回引用和返回变量

返回变量

返回变量的优点包括:

  • 安全性:

    返回一个值的副本可以保证调用者无法直接修改原始对象,因为它们操作的是不同的副本。

  • 简单性:

    对于小型对象或内置类型(如整数、浮点数等),返回值副本通常更加简单和高效。

  • 线程安全性:

    在多线程环境下,每个线程可以安全地操作自己的返回值副本,避免了竞态条件的问题。

一些缺点:

  • 性能开销:

    返回大型对象时,需要进行复制操作,可能会带来较大的性能开销,尤其是在频繁调用和处理大对象时。

返回引用

返回引用意味着函数返回的是原始对象的引用,调用者可以直接访问和修改原始对象。

优点包括:

  • 性能效率:

    避免了返回值时的对象复制,节省了时间和空间开销,特别是对于大型对象而言。

  • 直接操作对象:

    允许调用者直接修改原始对象的状态,适用于需要在函数内部修改对象状态并且这些修改需要在函数外部保持的情况。

潜在的问题和注意事项:

  • 潜在的安全性问题:

    调用者可以通过返回的引用直接修改对象,这可能导致意外的副作用或错误状态,尤其是在多线程或复杂逻辑下。

  • 生命周期管理:

    返回引用时需要确保引用的对象在函数返回后仍然有效,避免返回了一个垂悬引用

选择

通常情况下,可以遵循以下原则来选择返回变量或返回引用:

  • 小型对象或内置类型:优先考虑返回变量,因为复制开销较小,而且不会涉及引用悬挂的问题。

  • 大型对象:考虑返回引用,以避免复制开销,但需注意引用的有效性和可能的副作用。

  • 需要修改原始对象状态:只能返回引用,因为返回值的复制将不会影响原始对象。

定义一个类就是定义一个类型。

基本形式,注意分号

1
2
3
4
5
6
7
class Class
{
int a;
char b;
float c;
void d();
};

从结构到类:

概念解释
结构单纯堆积数据空间构造的类型。
不但描述数据空间,还描述其操作的自定义类型。
变量由内部数据类型或衍生的结构类型所产生的实体。
对象由类产生的实体。
本质上,变量也是对象,只不过简单一点罢了。

类的作用:

  • 外壳保护作用
  • 外壳内外分明
  • 接口标准清晰
  • 责任方便维护

成员

成员的权限是对外的直接权限,对内没有权限限制。外部包括:非成员函数和其它类的成员函数。

privateprotected只有在继承时有区别。

成员函数之间相当于同作用域互相可见,非成员函数不在相同作用域:

  • 作用域
    • 成员函数属于类, 成员函数定义是类设计的一部分, 其作用域是类作用域。
    • 普通函数一般是全局函数。
  • 操作主体
    • 成员函数的操作主体是对象,使用时通过捆绑对象来行使其职责。
    • 普通函数被调用时没有操作主体。

占有的内存

实例化的类占有的内存与非静态数据成员和虚函数(因为要构造虚函数表)有关。

静态数据成员和成员函数不占用实例化的类的空间。

需要注意的是,即便一个类只有静态数据成员和成员函数,它实例化后占有的空间也不会是0,而至少是1个字节,因为需要给它分配地址,则至少要有1字节。

作用域

变量的作用域是{}内或其后的全部内容,例如:

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
#include<iostream>
using namespace std;
int a=1;
int main()
{
void f(void);
cout<<a<<endl;
//输出全局变量a
int a=2;
cout<<a<<endl;
//输出main中定义的a

cout<<::a<<endl;
//全局对象被屏蔽后的强行访问
{
int a=3;
cout<<a<<endl;
//输出新域中的a
}
cout<<a<<endl;
//输出main中定义的a
f();
cout<<a<<endl;
//输出main中定义的a
}
void f()
{
cout<<a<<endl;
//输出全局变量a
int a=4;
cout<<a<<endl;
//输出新域中的a
}

类定义的作用域也等同于变量:

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
class A
{
int a;
};
void f()
{
class B
{
int b;
};
A s; //正常
B t; //正常
{
class C
{
int c;
};
A s; //正常
B t; //正常
C u; //正常
}
A s; //正常
B t; //正常
C u; //报错
}
int main()
{
A s; //正常
B t; //报错
C u; //报错
}

区分类定义作用域和类作用域:

  • 类定义作用域:
    • 从类定义结束开始,到从外面包围类定义的块结束(若类定义外无包围块,则结束于文件)。
    • 在类定义作用域中,可以定义这个类的变量。
    • 使用类的程序员在类定义作用域下编程。
  • 类作用域:
    • 类定义内部及成员函数定义内部。
    • 实现类的程序员在类作用域下编程。

如果需要在类作用域外进行定义,需要使用运算符::

内联函数

可以在函数声明前加上inline语句,进行显性定义或者声明。复杂语句(循环、多分支)不适合内联。小的成员函数如果在类内定义,一般编译器会默认为内联函数。内联函数可以提高代码效率。

内联函数的定义和声明不能分开。

对于inline限定的函数,具体是不是采取内联方式编译是取决于编译器的。对于较大较复杂的函数,编译器不会内联编译,所以只能说inline是给编译器的一个“建议”。

成员函数

成员函数不属于对象,通过内部隐含*this的定义A *const this=&对象X来知道调用者是谁。

成员函数的作用域

可以参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//Student.h中:
class Student
{
public:
void p();
float score;
protected:
char name;
int age;
void x(){/*成员函数x()*/}
};
//Student.cpp中:
void x(){/*新的x()*/}
void Student::p()
{ int age = 1;
age=5;
this->age=5;
Student::age = 5;
//必须要有this->或::用来区分p()中新定义的age和类中的
x(); //成员函数x()
::x(); //新的x()
}

常成员函数

常成员函数不允许出现改变对象状态的行为,此时this是一个指向常量的指针。例如:

1
2
3
4
void Student::p(const int a) const
{
cout<<age<<a;
}

除此之外,还有一些例如常量对象(已经被const处理了)调用成员函数的问题。常量对象不能调用改变对象状态的成员函数。

函数重载

重载以以下的因素为依据:

  • 参数类型
  • 参数个数
  • 参数顺序
  • 参数是否是常量指针或常引用
  • 常成员函数

但是要注意的是:

  • 返回类型不能够作为重载的依据。
  • 所有的重载函数需要在同一个定义域中。
  • 如果普通函数不存在,那么都调用常成员函数。
    关于常成员函数的重载,也可以参考:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>
using namespace std;
class A
{
public:
int a;
void p()
{
cout<<"not const"<<endl;
}
void p() const
{
cout<<"const"<<endl;
}
};
int main()
{
A a;
const A b=a;
a.p(); //调用非常成员函数
b.p(); //调用常成员函数
}
  • 只有常引用或常量指针才能重载,如果是下面这种情况,那么不会构成重载,因为不是空间权限的传递,而是值的传递:
1
2
3
4
5
6
7
8
void p(A const s)
{
cout<<2;
}
void p(A s)
{
cout<<1;
}
  • 对于可以类型转换的参数,按照下面的顺序进行重载:

    1. 严格匹配
    2. 其次相容类型匹配
    3. 最后用户定义类型转换

    这样来尽量避免类型相容二义性,用“名称压轧技术”实现。

  • 默认从左端开始匹配重载。

默认参数

重载是从左端开始匹配的,但是默认参数从右端开始设置,例如:

1
2
3
4
5
6
7
8
9
10
11
int x(int i,int j=11,int k=12)
{
cout<<i<<j<<k;
}
void main()
{
x(); //错误
x(1); //输出结果: 1 11 12
x(1,2); //输出结果: 1 2 12
x(1,2,3); //输出结果: 1 2 3
}

默认参数会对依靠参数进行重载的函数产生影响,因为会改变参数的数量,可能造成二义性。

模版

模板与函数

使用泛类型。例如定义:

1
2
template <typename T>
void swap(T &a,T &b);

函数在得到参数以后,按照类型反向推演出实例化的函数,下一次再使用时,就不再进行推演实例化。其参数类型必须严格匹配,否则会导致推演错误:

1
2
3
float a;
int b;
swap(a,b);

但是如果指定了类型,就会强制类型转换:

1
2
3
float a;
int b;
swap<int>(a,b); //显式

和函数默认参数一样,模板也可以有默认参数,例如:

1
template <typename S,typename T=int>

需要注意的是,模板类型声明不能共享,typename不能节省。例如:

1
2
3
4
5
6
7
8
9
10
template<typename T,typename U>
void add(T& a,U& b)
{
/**/
}

void swap(T& a,T& b) //错误
{
/**/
}

如果同时有非模板函数和模板函数,则优先调用非重载函数:

1
2
3
4
5
6
7
8
9
template <typename T>
void swap(T& a,T& b)
{
/**/
}
void swap(int* &a,int* &b) //优先调用
{
/**/
}

也可以有模板重载,但VC++不支持:

1
2
3
4
5
6
7
8
9
10
template <typename T>
void x(T x)
{
/**/
}
template <typename T>
void x(T *x)
{
/**/
}

与函数类似,类也可以进行模板操作。

模板函数可以使用export关键字进行多文件编程,不过代价太大,主流编译器均不支持。

名字空间和嵌套类

名字空间是一个作用域,用于解决名字冲突的风险,可以包含变量、对象,函数和类型的定义。可以开放定义或在{}外定义。一个类本身就是一个特殊的名字空间。using namespace语句类似于友元。namespace语句可以为名字空间起一个别名。

一个类可以嵌套另一个类。嵌套类基本是类内和类外的关系,对象空间不包含。外界访问被嵌套类受权限制约,而被嵌套类可以无条件访问外围类的静态成员、类型名称。嵌套类的意义在于隐藏代码和避免头文件包含。

构造函数和析构函数

构造函数就是在构造一个类时,将该类的所有成员初始化的函数;析构函数则是在一个类结束生命时(函数结束或者被delete),将所有的类成员消灭,回收它们占有的空间的函数。

构造函数与类同名,没有返回类型,构造时自动调用,可以重载。可以用于初始化,不能直接调用。

析构函数也没有返回类型,析构时自动调用,没有参数,不能重载。不提倡主动调用析构函数。

成员初始化列表

在构造函数函数体前面可以采用类似于A():a(b),c(d)的操作,称为成员初始化列表。

成员初始化列表不同于赋值。赋值是在对象存在以后再进行操作,而初始化则是直接在对象构造完成前进行操作。

对于引用和const成员的初始化,只能在成员初始化列表中实现。

执行顺序

构造函数在构造时执行,全局变量的构造函数在main()函数之前执行,若全局对象分散在不同的源文件中,那么构造顺序时随机的。静态对象的构造函数在首次被调用时执行一次,在程序结束时析构。

从复用的结构而言,对于有继承和组合的类,先执行包含的类或继承的父类的构造函数,以文本上的定义顺序执行构造,而与构造函数初始化列表中ClassA():ClassB(x),ClassC(y){}的执行顺序无关。

对象析构时,按构造函数执行的逆序进行。

对于对象数组,在用delete[]进行析构时,先析构靠后的,之后一个一个向前析构。

构造函数的调用

成员类不允许在定义时初始化,因为它仅仅是一个类型,而没有空间分配。例如:

1
2
3
4
5
6
7
8
9
10
11
class Student
{
string name;
StudentID id;
public:
Student(string pName,int ssID=0)
{
name=pName;
StudentID id(ssID); //错误
}
};

关于动态内存

malloc()和对应的free()函数不属于语言本身,于malloc.h中声明,因此在用它们执行申请动态内存时不能自动调用构造函数和析构函数。所以引入了newdelete关键字。

通过new关键字申请的动态空间位于堆区,不会主动释放,因此使用new创建的新对象不会自动在程序结束时调用析构函数释放它自己,需要主动管理。因此,对于含有指针和动态内存的类,也需要在析构函数中执行delete[]语句释放空间。

需要注意的一点是,用malloc()free()构造和消灭的对象是不会主动调用构造函数和析构函数的。

拷贝

如果让一个A类型的类进行A b=a;这样的操作,则会调用(默认的)拷贝构造函数A(const A&),为b初始化。

调用拷贝构造函数产生的效果分为浅拷贝和深拷贝。浅拷贝指的是只拷贝地址,深拷贝则是拷贝了地址上的内容。

类型转换

在将一个输入进行类型转换,转换成一个类时,通常会调用对应的构造函数。

如果构造函数用了explicit关键字,那么则不接受隐式的类型转换。例如,如果一个类A只有从int构造的构造函数,那么如果加上了explicit之后,A a=1;这样的操作是不合法的,因为不能够隐式调用它了,去掉了则可以。

类型转换并不是万能的,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class aa 
{
public:
aa(int a=1){id=a;}
int id;
};
class bb
{
public:
bb(int a=2){id=a;}
int id;
};
void m(aa a)
{
cout<<a.id<<endl;
}
void m(bb a)
{
cout<<a.id<<endl;
}
void main()
{
m(9); //存在二义性
} //转换不能太复杂,不允许多参数,不允许间接转换

默认的构造函数与析构函数

默认构造函数就是不带任何参数的构造函数,如果没有给一个类定义构造函数,编译器就会自己给它加上一个默认的,把类里面的所有参数都初始化为0。默认析构函数同理。

如果把一个构造函数定义为Myclass()=default;也可以显式将其声明为默认的构造函数。

同样,对于拷贝构造函数,如果没有给一个类显式定义,那么编译器也会默认生成一个。

友元

将一个函数或类设置友元可以让其获得访问此类的protectedprivate成员。可以解决一些效率问题和格式问题。友元是单向的,且不具备传递性。它一定程度上破坏了类的封装性,扩大了自由函数与类对类的访问权限,谨慎使用。

运算符重载

利用函数机制重载运算符,不过其成员不能全是基本类型,其内部的同名运算符保持原来的含义,优先级也不会改变。重载的运算符可以是成员函数也可以是非成员函数。

赋值运算符、自增自减运算符应当返回引用。

成员或非成员

权限
只能是成员的运算符=()[]->
只能是友元的运算符<<>>
既可以友元也可以成员的+-etc.
不能被重载的运算符::.

但是,如果一个运算符是成员,那么它的左边必须是一个类类型。所以对于混合使用的运算符,例如+

1
2
string s="hello";
string t="hello"+s; //错误:"hello"不是一个类

一般的算术运算符

如果是在类外定义运算符重载,则和一般写函数差不多,参数是两个。

如果是在类内定义算术运算符,参数只有一个,而且需要注意返回的应当是*this的引用:

  • 支持链式操作符:

    • 返回 *this 允许类实例在操作符链中进行修改和连续调用。例如,如果一个类重载了 + 运算符返回 *this,那么可以连续对该对象进行操作,而不需要每次都创建新的对象。
  • 原地修改对象:

    • 返回 *this 允许在不创建新对象的情况下,直接在当前对象上进行修改。这在某些情况下可以提高性能和效率,特别是对于一些复杂的数据结构或者需要频繁修改的对象。
  • 一致性和约定:

    • 返回 *this 是一种约定和惯例,它告诉用户重载的 + 运算符确实是在修改当前对象而非创建新对象。这样的约定对于代码的可读性和理解是有益的。

I/O运算符

重载输入、输出运算符的第一个形参是对非常量的ostreamistream对象的引用,因为我们不能直接复制一个ostream对象,而之所以是非常量对象,是因为向其中写入内容会改变其状态;第二个形参是对于类的引用,引用是为了避免复制实参,而它可以是常量是因为我们的操作不会改变它的状态。

重载输入、输出运算符的函数既可以是成员函数也可以是友元函数。

友元函数形同:

1
2
3
4
5
friend std::ostream &operator<<(ostream& os,const data& item)
{
os<<item.name()<<flush;
return os; //返回os形参
}

成员函数形同:

1
2
3
4
5
std::ostream &operator<<(ostream& os) const
{
os<<name()<<flush;
return os; //返回os形参
}

它们必须是一个非成员函数。如果它们是成员函数,那么它们的左侧是我们这个类的一个对象,然而左侧应该是istreamostream对象。

赋值运算符

赋值运算符的返回值一般是引用,否则不能连续赋值,因为每一次赋值都会产生一个临时变量,会影响到连续赋值的结果。

如果没有给一个类定义赋值运算符的重载,编译器就会生成一个默认的赋值运算符重载,和默认拷贝构造函数差不多。

自增、自减运算符

和赋值运算符一样,前置自增自减运算符的返回值也是引用。重载前置自增自减运算符++x的声明应该写成A& operator++();

后置的应该声明成A& operator++(int);,它接受一个不被使用的int形参,默认为0。后置版本可以不是引用。一般是生成一个A类型的ret记录*this,然后操作*this,返回ret

使用p.operator++()p.operator++(0)可以显式调用该运算符。

静态成员

静态成员的空间不包含在对象中,在main()之前构造,生命周期等同于全局对象。多个个体对象共享一个共有数据。

静态成员函数不再与对象this默认联系,但可以访问非public成员。

关于static关键字

static有三种用法

  • 函数外的静态变量(与 extern 相对)

    内部链接性变量,仅能在一个编译单元中使用,不与其他编译单元共享。

  • 函数中的静态变量(与栈空间中的局部变量相对)

    位于变量数据区,因此函数返回时不析构的变量。

  • 类中的静态成员变量(与对象成员相对)

    所有实例共享的变量。

继承

继承的本质实际上就是由上到下完全的复制,但是继承方式在对内可见性上做了手脚;对外可见性则没有改变。继承后的对外权限等于或低于继承类型 。继承类型省略默认为private继承。private成员继承后只对原来兄弟方法可见。子类不能访问父类中的private成员,父类不能也做不到访问子类的所有内容。

派生的内容则等同于原始类的定义。派生类对象包含父类对象全部内容,凡是父类对象可以出现的地方都可以用子类对象代替,反之不可。例如:

1
2
3
4
5
class B: public A
{
/**/
};
A* p=new B; //这是允许的

public继承使用的最多,是最重要的,产生的后代称为“子类”,protected继承和private继承得到的类都不是子类“凡是父类对象可以出现的地方可以用子类对象代替”不再适用

如果子类与父类中的成员(函数或对象)重名,则子类覆盖父类的成员。需要使用::强行访问。

构造函数、拷贝构造函数、析构函数和赋值函数不能被继承。

组合与继承

组合指的是一个类中有另一个类为其成员,继承则是完全的另一种复用方式,因此,不能用组合类代替被组合类实现类似于子类代替父类的用法。因为没有继承关系,所以组合中,protected也等效于private

组合和继承都是实现复用的手段,但是尽量使用组合,因为是黑箱复用依赖较少。被组合的成员内部的成员和本级其他成员,相互访问权限相当于类外关系。被组合成员内部的成员的成员的成员,etc,外面对其访问权限属于逐级类外叠加的关系。

继承与构造函数

子类的构造函数可以在{}中初始化父类的成员,但不提倡。不过,如果id是父类A中的成员B():id(9){}这样的操作是不允许的,只能用B():A(9){}这样的操作调用父类的构造函数。

隐藏

如果子类中有一个与父类完全相同的成员函数定义,那么会覆盖掉父类中原本的函数,可以使用::强行访问。但是要注意与重载的区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Automobile
{
public:
void Run()
{
cout<<"机械变速"<<endl;
}
};
class Car:public Automobile
{
public:
void Run(int i)
{
cout<<"自动变速"<<endl;
}
};
int main()
{
Automobile ObjA;
Car ObjB;
ObjA.Run();
ObjB.Run(); //编译错误,不是重载
ObjB.Automobile::Run();
}

以及:

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
class Automobile
{
public:
void Run()
{
cout<<"“机械变速”"<<endl;
}
void Run(int i)
{
cout<<"“机械变速1.0”"<<endl;
}
};
class Car:public Automobile
{
public:
void Run()
{
cout<<"“自动变速“"<<endl;
}
};
int main()
{
Automobile ObjA;
Car ObjB;
ObjA.Run();
ObjA.Run(1);
ObjB.Run();
ObjB.Run(2); //出错
ObjB.Automobile::Run(2); //正常
}

有的时候需要注意隐性的类型转换:

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
class Automobile
{
public:
void Run()
{
cout<<"“机械变速”"<<endl;
}
};
class Car:public Automobile
{
public:
void Run()
{
cout<<"“自动变速“"<<endl;
}
};
void Fn(Automobile *p) //进行了类型转换
{
p->Run();
delete p;
}
void main()
{
Fn(new Automobile); //机械变速
Fn(new Car); //机械变速
}

虚函数与多态

只不过我们希望,Fn(new Car)这个操作能输出自动变速,这就需要多态,所以需要引入虚函数,用virtual关键字定义。派生类中的函数只要原型相同,则自动具有虚函数性质,其virtual关键字可省略。

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
class Automobile
{
public:
virtual void Run()
{
cout<<"“机械变速”"<<endl;
}
};
class Car:public Automobile
{
public:
virtual void Run()
{
cout<<"“自动变速“"<<endl;
}
};
void Fn(Automobile *p) //进行了类型转换
{
p->Run();
delete p;
}
void main()
{
Fn(new Automobile); //机械变速
Fn(new Car); //自动变速
}

多态的意义在于:

  • 是追求用指针管理继承体系对象的结果。

  • 多态性使得应用程序使用类体系中的不同层次对象共存的复杂局面达到了一种可管理的境界;程序员从使用孤立的类,到使用分层的类,让各种对象“同场竞技”,并且能充分展现其个性。

  • 不支持多态的语言不能称为面向对象的语言。

编译器通过函数名、参数个数、参数类型、参数顺序相同判断是否覆盖基类虚函数。但是如果两个虚函数的返回值分别是引用和相同类型的变量,那么并不会判定为两个虚函数。

虚函数的工作原理在于动态联编

联编编译调用
静态联编
(早期联编)
编译时完成对象调用函数、非多态调用
动态联编
(滞后联编)
运行时完成多态调用

静态联编可在编译时确定,是因为可以确定被调用函数所在的类。动态联编的祖先指针指向了一个后代对象,但是不容易知道其类型,于是凡有虚函数的类均要维护一个虚表,实例化每个对象时为其增加一个指针,并指向这个虚表(与类型对应),虚函数调用时不需确定对象类型,通过该虚指针即可找到所要链接函数,这样才能确定链接函数是哪个类的。

虚函数调用的this指针长度也于非虚函数不同。非虚函数this指针自调用者开始,加上调用者类型长度为止(实现者=调用者),虚函数this指针自调用者开始,加上实现者类型长度止(实现者=对象的类型)。虚函数不予编译检查(虚指针的值无法确认而无法检查),因此无错,执行时则实现者类型已知(因为有虚表),才会出错。

多态的应用场景主要有指针、引用和成员函数,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class X
{
public:
virtual void f1(){cout<<"Running X::f1()"<<endl; f4();}
virtual void f2(){cout<<"Running X::f2()"<<endl; f3();}
virtual void f3(){cout<<"Running X::f3()"<<endl; f4();}
virtual void f4(){cout<<"Running X::f4()"<<endl;}
};
class Y:public X
{
public:
void f1(){cout<<"Running Y::f1()"<<endl; f2();}
void f3(){cout<<"Running Y::f3()"<<endl; f4();}
};
void main()
{
X &p =*(new Y);
p.f1();
}

虚函数使用注意事项:

  • 构造函数和析构函数调用虚函数时,不支持多态。

  • 析构函数是推荐作为虚函数的,例如A *p=new B;delete p;,这样delete掉的地址理论上是只有A的那一部分,调用父类A的构造函数,不会调用子类B的构造函数,容易造成内存泄漏。

  • 构造函数不能是虚函数

  • 非成员函数、静态成员函数和内联函数不能是虚函数。

函数签名

函数签名指的是函数的声明部分,包括函数的名称、参数类型及其顺序(参数列表),但不包括函数的实现体或函数体:

  • 函数名称

  • 参数的数量

  • 每个参数的类型(按顺序,const属性也包括在内)

  • 返回类型不影响函数签名

函数签名的目的是唯一标识一个函数的调用形式,它在编译和链接过程中用于解析函数调用。编程语言通常要求函数签名在同一作用域中唯一,即不同函数在同一作用域下不能有相同的函数签名,以确保函数调用的准确性和一致性。

虚函数的覆盖就是根据函数签名是否一致来判断是否要覆盖掉父类同名的这个虚函数。例如:

1
2
3
4
5
6
7
8
9
10
11
class Base 
{
public:
virtual void func();
};

class Derived:public Base
{
public:
virtual int func() override;
};

在这个例子中,Derived类中的func()仍然会覆盖掉基类的同名函数,尽管它们的返回类型不一样。

抽象类

如果基类中的一个虚函数不能给出有意义的实现,那么一般就会将它声明为纯虚函数,例如virtual void p()=0;

含有纯虚函数的类被称作抽象类。这些类没有独立于具体类存在的意义,纯粹为了抽象而存在,它们不能被实例化为对象,一般用来作为基类继承,但是可以有指针或引用

如果一个类继承了一个抽象类,但是没有将所有的纯虚函数virtual void p()变为一般的虚函数,那这个子类还是一个抽象类。纯虚函数可以有定义代码,供强行访问。析构函数可以是纯虚函数,但是必须要有定义代码。

抽象类可以使得数据结构更为清晰。

多重继承

如果一个类,同时继承自两个父类,两个父类中还分别有一个名字一样的成员,那么在调用它们时必须要用::区分。

但是如果是产生了“孙继承”,例如两个类MasterPhD分别继承自同一个基类Student,然后一个新类M_P继承自MasterPhD,就会导致混乱,MasterPhD的域下面各有一个Student域,操作起来会很麻烦,也不能使用Student* p=&M_P这样的写法,因为Student是不明确的。

这样的话,就可以采用虚继承,在继承时加上virtual关键字。M_P则是分别继承自MasterPhD(这两个理论上也继承自Student,但是不会实例化)和Student三个基类。但是,这个时候Student* p=&M_P是成立的,因为,Student*指针是明确的,就是M_P继承的那个Student基类。MasterPhDStudent基类会产生一个虚指针,供这两个类在访问它们的父类时使用。虚拟继承的基类直接派生类构造函数放弃向上传导。这种情况下,Student只会实例化一次。

需要注意的是,虚拟继承和虚函数(多态、抽象类)没有关系。实际问题也应尽量避免多重继承。

终结类

如果给一个类加上final关键字,会让它不能被继承,称为终结类。

把基类的构造函数设为private也可以让它不能被继承。

I/O类

I/O流

ostreamistream是类的名称,iostream是该类的头文件

ostream &operator<<(ostream out,char *p);

ostream &operator<<(ostream out,int p);

ostream &operator<<(ostream out,char p);

ostream &operator<<(ostream out,float p);

coutcinostreamistream类的全局对象,不可复制。

定义默认输入/输出
ostream cout(stdout);标准输出默认为屏幕
ostream cin(stdin);标准输入默认为键盘
ostream cerr(stderr);标准出错默认为屏幕
ostream clog(stdprn);标准打印默认为打印机

I/O操纵器

头文件iomanip中包含I/O操纵器(manipulator),一些常用的操纵器有:

控制器用途
std::setw()设置字段宽度
std::setprecision()设置浮点数的精度
std::setfill()设置填充字符
std::leftstd::rightstd::internal设置对齐方式
std::fixedstd::scientific设置浮点数的输出格式

文件流类

ofstream是文件输出类,ifstream是文件输入类,它们在fstream.h里面定义。fstream是多继承子类。文件流类没有cout和cin这样的标准全局对象:

1
ofstream::ofstream(char *pFilename,int mode=ios::out,int port=filebuf::openprot);
打开方式解释
ios::ate如果文件存在末尾追加
ios::trunc如果文件存在清除内容(默认)
ios::in输入能力(ifstream默认)
ios::out输出能力(ofstream默认)
ios::nocreate文件不存在返回错误
ios::noreplace文件存在返回错误
ios::binary二进制方式
保护方式解释
filebuf::openprot允许共享
filebuf::sh_none独占
filebuf::sh_read读共享
filebuf::sh_write写共享

串流类

ostrstream是串输出类,istrstream是串输入类,在strstream.h里面定义。strstream是多继承子类。

也没有coutcin这样的标准全局对象:

ostrstream::ostream(char * ,int size);

istrstream::istream(char * ,int size);

异常处理

throw语句抛出异常,try标记一块代码,catch处理异常,可有一个以上,只捕获try标记的代码块中抛出的异常。try只能有一个形参。

如果发生了异常,则只中断try中的代码块。trycatch必须相邻,顺序不能颠倒;throwcatch可以跨函数放置。对于一般的参数类型,catch的参数是严格匹配的,如果没有catch捕获异常,则会调用abort()函数。

catch(基类类型)能够捕获throw 派生类对象catch(基类指针)能够捕获throw 派生类指针,反之不可以。所以catch(基类)总放在catch(派生类)后面。


面向对象程序设计课程笔记
https://blog.kisechan.space/2024/oop/
作者
Kisechan
发布于
2024年6月1日
更新于
2024年6月28日
许可协议