Cpp 面向对象——访问和继承权限
在 C++ 的面向对象编程中,有三个访问权限限定词:public、private 和 protected,用于定义对类成员(成员包括变量和函数)的可访问性,
也包括在继承后的访问权限变化。
我们主要对class进行讨论,最后会讨论struct和class的区别。
访问权限
C++ 通过访问权限限定词来提供数据封装和信息隐藏机制,这有助于提高代码的可读性和可维护性,三种访问权限限定词的基本语义如下:
public成员:公开的类成员,可以通过类的成员函数和类的对象访问。protected成员:受保护的类成员,只能通过类的成员函数访问,无法通过类的对象访问。private成员:私有的类成员,只能通过类的成员函数访问,无法通过类的对象访问。
注意这里访问的含义:对数据成员的访问是读写,对成员函数的访问则是函数调用。
对于数据的访问权限并没有被进一步拆分为只读和可读写,只读的访问效果需要基于const实现,实现只写的访问效果则需要考虑左值和右值特性,不在本文的讨论范围。
通过类的对象访问是指a.x和a.show()这类语法。
通过类的成员函数访问是指在成员函数体内部对类的其它成员访问,因为它们都是同一个类的成员,相互之间理应是完全开放的,访问权限在此时通常是无意义的,
但是有一个例外:基类的private成员对派生类是完全隐藏的(无论采用哪种继承方式),派生类的成员函数无法对其进行访问。
我们目前没有考虑继承权限,只有在引入继承权限后才会体现出protected和private的区别,见下文。
关于访问权限的示例语法如下
1 | class Base { |
注意在缺省关键词时默认为private权限。
我们可以通过下面的代码探究通过成员函数和类对象访问类的成员的情况
1 |
|
探究的结果如下:
- 在类的内部通过成员函数访问时,总是可以自由地访问所有的成员,无论它们的权限是什么,例如这里在
Base::set成员函数中可以访问所有的数据成员(this->x,this->y,this->z)。 - 在类的外部通过对象访问时,情况却是未必的:对
public成员demo.x可以成功访问,但是对protectd成员demo.y和private成员demo.z的访问会触发编译错误。
继承权限
在自定义类型之间有public, protected, private三种继承方式:
public继承:- 基类的
public/protected成员的访问属性在派生类中仍然为public/protected,即访问属性保持不变; - 基类的
private成员在派生类中完全隐藏,但是在内存中仍然存在; - 通过派生类的成员函数可以访问基类中的
public/protected成员; - 通过派生类的对象只能访问基类的
public成员;
- 基类的
protected继承:- 基类的
public/protected成员的访问属性在派生类中统一变为protected; - 基类的
private成员在派生类中完全隐藏,但是在内存中仍然存在; - 通过派生类的成员函数可以访问基类中的
public/protected成员; - 通过派生类的对象无法访问基类的任何成员;
- 基类的
private继承:- 基类的
public/protected成员的访问属性在派生类中统一变为private; - 基类的
private成员在派生类中完全隐藏,但是在内存中仍然存在; - 通过派生类的成员函数可以访问基类中的
public/protected成员; - 通过派生类的对象无法访问基类的任何成员;
- 基类的
关于继承权限的示例语法如下
1 | class Base {}; |
注意在缺省关键词时默认为private继承,在多继承时对每一个基类需要分别加上关键词。
通过下面的代码可以探究:在不同访问权限和不同继承权限下,派生类的成员函数或派生类对象对基类成员的访问
1 |
|
这里注释掉的都是无法编译通过的访问操作,我们验证了如下结论:
- 派生类的成员函数总是可以访问基类中的
public/protected成员; - 只有在基类成员为
public,并且通过public继承时,才能通过派生类对象来访问对应的基类成员。
通过下面的代码可以探究:在不同访问权限和不同继承权限下,派生类继承自基类的成员的访问属性变化
1 |
|
这里注释掉的都是无法编译通过的访问操作,我们验证了如下结论:
public继承时,基类的public/protected成员仍然为派生类的public/protected成员;protected继承时,基类的public/protected成员统一变成派生类的protected成员;private继承时,基类的public/protected成员统一变成派生类的private成员。
小结
在同时考虑了访问权限和继承权限之后,访问权限的完整含义如下:
public成员:可以通过类或派生类的成员函数,也可以通过类或派生类的对象访问。protected成员:只能通过类或派生类的成员函数访问,无法通过类或派生类的对象访问;private成员:只能通过类的成员函数访问(对派生类完全隐藏),无法通过类或派生类的对象访问。
尝试从派生类的角度(成员函数或对象)访问基类成员时:
- 通过派生类的成员函数访问基类成员时,要求基类成员为
public/protected成员,与继承方式无关。 - 通过派生类的对象访问基类成员时,要求基类成员为
public成员,并且采用public继承,其它情形下均不可以。 - 基类的
private成员对派生类来说是完全隐藏的,无论通过成员函数还是对象,都无法访问。
不同的继承权限会导致基类成员在派生类中的访问权限发生变化:
public继承:对基类的public/protected成员的访问权限保持不变;protected继承:将基类的public成员的访问权限变为protected,protected成员的访问权限保持不变;private继承:将基类的public/protected成员的访问权限变为private。
补充
友元
我们有时希望允许一个外部的函数或者类,在其中可以直接通过当前类的对象来访问所有内部成员,这对于运算符重载是很常见的需求。
C++允许我们使用friend将外部函数或类声明为当前类的友元,赋予它完整的访问权限。
例如将一个外部函数声明为友元,赋予这个函数完全的访问权限
1 | class Box { |
既可以在外部定义友元函数(在外部定义时无需添加friend),也可以直接在内部定义(变成内联函数)。
需要明确的是,对函数的友元声明只是表明它是友元,并不是通常意义下的函数声明,如果需要在定义之前使用函数,还是需要提供额外的函数声明。
对于自定义类型的二元运算符重载,通常都会将其实现为非成员函数,并且声明为友元,以体现运算符的对称性,当然这不是必须的,我们仍然可以将其实现为第一个变量的普通成员函数。
在涉及到流式输入输出时,对>>和<<的一元运算符重载必须将其声明为友元函数,因为我们无法修改cout。
补充:C++语法规定
[]、()、->以及++--的运算符重载只能作为成员函数出现,不能作为非成员函数出现。
例如
1 | class Complex { |
除了友元函数,也可以将一个外部类声明为友元,赋予这个友元类完全的访问权限,例如
1 | class B; // 前向声明 |
更安全的做法是只将外部类的某个成员函数声明为友元,而不是将整个类变成友元,但此时循环依赖的问题有点麻烦
1 | class A; // 前向声明 |
注意:
- 在类的声明内部,声明友元的位置是无所谓的,不会受到
private/protected/public的影响。 - 友元破坏了类的封装:友元关系允许访问私有数据,这违背了面向对象编程的封装原则,应谨慎使用。
- 友元关系是单向的:如果类A是类B的友元,类B不会自动变成类A的友元,除非显式地声明。
- 友元关系不具有继承性:如果基类声明了一个友元函数/友元类,派生类不会自动继承这个友元关系。
- 友元关系不具有传递性:即类B是类A的友元,类C是类B的友元,类C不会自动变成类A的友元,除非显式地声明。
struct vs class
struct和class这两个关键词都可以用于定义自定义类型,struct是延续自C语言的关键词,而class则是专属于C++的关键词。
在定义类型时,两者几乎所有的效果都是一样的,除了缺省访问权限限定词时的默认行为不一样:(这也是为了兼容C语言中的结构体语法)
- 用
struct定义时,默认的访问权限是public,默认的继承权限也是public; - 用
class定义时,默认的访问权限是private,默认的继承权限也是private。
通过两个关键词定义得到的类实质上没有任何区别,但是从使用习惯的角度有如下的建议:
struct建议用于定义简单的数据结构,仅包含公有的数据成员而没有函数成员,就像C语言中的结构体,例如数据传输对象或无行为的数据结构;class建议用于定义具有封装、继承和多态特性的复杂对象和类,具有成员函数,具有非公开成员。
这种建议的内在逻辑是:
- 一个结构体中的各个变量是相关的,但是可以独立地自由变化,不需要对外隐藏变量访问权限,因此也不需要对外提供专门的操作方法;
- 一个类中的各个变量通常不能自由变化(不是所有的值组合都能保证当前类的对象处于一个有效的状态),因此不允许外界直接修改,只能通过专门的操作方法完成,在这些操作方法中负责维护状态的有效性:可能在操作过程中会导致对象暂时处于非法的状态,但是可以保证操作结束后的对象一定处于一个有效的状态。
例如
1 | struct Point { |
突破权限约束
需要注意的是,这些权限信息只在编译期被用于语法检查,并不会体现在可执行程序中,在运行期这些信息已经被全部抹除了,
因此我们可以很容易地通过函数指针和偏移量等技巧来绕过权限控制,编译器无法对这种行为进行任何检查。
这种情况也是有实际需求的,例如在单元测试中用来访问私有方法和属性。
一个简单粗暴的办法是通过宏定义将private和protected全部变为public,或者把 class 变成 struct,然后重新编译。
1 |
利用友元函数和类模板也可以做到在不修改原始类型的情况下,直接读写其私有成员,例如
1 |
|
最后是编译器直接提供的,最简单粗暴的方法:编译时加上编译选项-fno-access-control,直接关闭编译期的访问权限控制。
gcc支持这个选项,其它编译器可能也提供了类似的选项。
