Cpp 面向对象——访问和继承权限
在 C++
的面向对象编程中,有三个访问权限限定词:public
、private
和
protected
,用于定义对类成员(成员包括变量和函数)的可访问性,
也包括在继承后的访问权限变化。
我们主要对class
进行讨论,最后会讨论struct
和class
的区别。
访问权限
C++ 通过访问权限限定词来提供数据封装和信息隐藏机制,这有助于提高代码的可读性和可维护性,三种访问权限限定词的基本语义如下:
public
成员:公开的类成员,可以通过类的成员函数和类的对象访问。protected
成员:受保护的类成员,只能通过类的成员函数访问,无法通过类的对象访问。private
成员:私有的类成员,只能通过类的成员函数访问,无法通过类的对象访问。
注意这里访问的含义:对数据成员的访问是读写,对成员函数的访问则是函数调用。
对于数据的访问权限并没有被进一步拆分为只读和可读写,只读的访问效果需要基于const
实现,实现只写的访问效果则需要考虑左值和右值特性,不在本文的讨论范围。
通过类的对象访问是指a.x
和a.show()
这类语法。
通过类的成员函数访问是指在成员函数体内部对类的其它成员访问,因为它们都是同一个类的成员,相互之间理应是完全开放的,访问权限在此时通常是无意义的,
但是有一个例外:基类的private
成员对派生类是完全隐藏的(无论采用哪种继承方式),派生类的成员函数无法对其进行访问。
我们目前没有考虑继承权限,只有在引入继承权限后才会体现出protected
和private
的区别,见下文。
关于访问权限的示例语法如下 1
2
3
4
5
6
7
8
9
10
11
12
13
14class Base {
public:
int x;
protected:
int y;
private:
int z;
};
class Base2 {
// default private
}
注意在缺省关键词时默认为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
class Base {
public:
int x;
protected:
int y;
private:
int z;
public:
void set(int x, int y, int z) {
this->x = x;
this->y = y;
this->z = z;
}
};
int main() {
Base demo;
demo.set(1, 2, 3);
demo.x = 10;
// demo.y = 20;
// demo.z = 30;
return 0;
}
探究的结果如下:
- 在类的内部通过成员函数访问时,总是可以自由地访问所有的成员,无论它们的权限是什么,例如这里在
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
2
3
4
5
6
7
8
9class Base {};
class Base2 {};
class Derived : Base {}; // default private
class Derived1 : public Base {};
class Derived2 : public Base, public Base2 {};
注意在缺省关键词时默认为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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class Base {
public:
int x;
protected:
int y;
private:
int z;
};
class Derived1 : public Base {
public:
void set(int x, int y, int z) {
this->x = x;
this->y = y;
// this->z = z;
}
};
class Derived2 : protected Base {
public:
void set(int x, int y, int z) {
this->x = x;
this->y = y;
// this->z = z;
}
};
class Derived3 : private Base {
public:
void set(int x, int y, int z) {
this->x = x;
this->y = y;
// this->z = z;
}
};
int main() {
Derived1 d1;
d1.set(1, 2, 3);
d1.x = 1;
// d1.y = 2;
// d1.z = 3;
Derived2 d2;
d2.set(1, 2, 3);
// d2.x = 1;
// d2.y = 2;
// d2.z = 3;
Derived3 d3;
d3.set(1, 2, 3);
// d3.x = 1;
// d3.y = 2;
// d3.z = 3;
return 0;
}
这里注释掉的都是无法编译通过的访问操作,我们验证了如下结论:
- 派生类的成员函数总是可以访问基类中的
public/protected
成员; - 只有在基类成员为
public
,并且通过public
继承时,才能通过派生类对象来访问对应的基类成员。
通过下面的代码可以探究:在不同访问权限和不同继承权限下,派生类继承自基类的成员的访问属性变化
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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
class Base {
public:
int x;
protected:
int y;
private:
int z;
};
class Derived1 : public Base {};
class Derived1_test : public Derived1 {
public:
void set(int x, int y, int z) {
this->x = x;
this->y = y;
// this->z = z;
}
};
class Derived2 : protected Base {};
class Derived2_test : public Derived2 {
public:
void set(int x, int y, int z) {
this->x = x;
this->y = y;
// this->z = z;
}
};
class Derived3 : private Base {};
class Derived3_test : public Derived3 {
public:
void set(int x, int y, int z) {
// this->x = x;
// this->y = y;
// this->z = z;
}
};
int main() {
Derived1_test d1;
d1.set(1, 2, 3);
d1.x = 1;
// d1.y = 2;
// d1.z = 3;
Derived2_test d2;
d2.set(1, 2, 3);
// d2.x = 1;
// d2.y = 2;
// d2.z = 3;
Derived3_test d3;
d3.set(1, 2, 3);
// d3.x = 1;
// d3.y = 2;
// d3.z = 3;
return 0;
}
这里注释掉的都是无法编译通过的访问操作,我们验证了如下结论:
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15class Box {
private:
double width;
public:
explicit Box(double w) : width(w) {}
// 声明友元函数
friend void printWidth(Box box);
};
void printWidth(Box box) {
// 可以通过Box类的对象,直接访问私有成员width
std::cout << "Width of box: " << box.width << std::endl;
}
既可以在外部定义友元函数(在外部定义时无需添加friend
),也可以直接在内部定义(变成内联函数)。
需要明确的是,对函数的友元声明只是表明它是友元,并不是通常意义下的函数声明,如果需要在定义之前使用函数,还是需要提供额外的函数声明。
对于自定义类型的二元运算符重载,通常都会将其声明为友元,例如
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15class Complex {
private:
double real;
double imag;
public:
Complex(double r = 0.0, double i = 0.0) : real(r), imag(i) {}
// 声明友元函数
friend Complex operator+(const Complex& c1, const Complex& c2);
};
Complex operator+(const Complex& c1, const Complex& c2) {
return Complex(c1.real + c2.real, c1.imag + c2.imag);
}
除了友元函数,也可以将一个外部类声明为友元,赋予这个友元类完全的访问权限,例如
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20class B; // 前向声明
class A {
private:
int value;
public:
A(int v) : value(v) {}
// 声明友元类
friend class B;
};
class B {
public:
void showValue(A& a) {
// 可以通过A类的对象,直接访问私有成员value
std::cout << "Value from class A: " << a.value << std::endl;
}
};
更安全的做法是只将外部类的某个成员函数声明为友元,而不是将整个类变成友元,但此时循环依赖的问题有点麻烦
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21class A; // 前向声明
class B {
public:
void showValue(A &a);
};
class A {
private:
int value;
public:
explicit A(int v) : value(v) {}
// 声明友元函数
friend void B::showValue(A &a);
};
void B::showValue(A &a) {
std::cout << "Value from class A: " << a.value << std::endl;
}
注意:
- 在类的声明内部,声明友元的位置是无所谓的,不会受到
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
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
34struct Point {
double x;
double y;
}
class Time {
private:
int m_hour{};
int m_minute{};
int m_second{};
public:
Time(int h, int m, int s) { set_time(h, m, s); }
void set_time(int h, int m, int s) {
if (h >= 0 && h < 24 && m >= 0 && m < 60 && s >= 0 && s < 60) {
m_hour = h;
m_minute = m;
m_second = s;
}
else {
std::cerr << "Invalid time! Resetting to 00:00:00." << std::endl;
m_hour = m_minute = m_second = 0;
}
}
std::string get_time() const {
std::ostringstream oss;
oss << std::setw(2) << std::setfill('0') << m_hour << ":"
<< std::setw(2) << std::setfill('0') << m_minute << ":"
<< std::setw(2) << std::setfill('0') << m_second;
return oss.str();
}
};
突破权限约束
需要注意的是,这些权限信息只在编译期被用于语法检查,并不会体现在可执行程序中,在运行期这些信息已经被全部抹除了, 因此我们可以很容易地通过函数指针和偏移量等技巧来绕过权限控制,编译器无法对这种行为进行任何检查。 这种情况也是有实际需求的,例如单元测试中。
一个简单粗暴的办法是通过宏定义将private
和protected
全部变为public
,然后重新编译。
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
class Bank {
double money = 1000;
public:
void check() const { std::cout << money << std::endl; }
};
template <typename T, auto Mp>
struct Thief {
friend auto &steal(T &obj) { return obj.*Mp; }
};
template struct Thief<Bank, &Bank::money>;
auto &steal(Bank &obj);
int main() {
Bank bank;
bank.check(); // 1000
steal(bank) = 0;
bank.check(); // 0
return 0;
}
最后是编译器直接提供的,最简单粗暴的方法:编译时加上编译选项-fno-access-control
,直接关闭编译期的访问权限控制。
gcc支持这个选项,其它编译器可能也提供了类似的选项。