在 C++ 的面向对象编程中,有三个访问权限限定词:publicprivateprotected,用于定义对类成员(成员包括变量和函数)的可访问性, 也包括在继承后的访问权限变化。

我们主要对class进行讨论,最后会讨论structclass的区别。

访问权限

C++ 通过访问权限限定词来提供数据封装和信息隐藏机制,这有助于提高代码的可读性和可维护性,三种访问权限限定词的基本语义如下:

  • public成员:公开的类成员,可以通过类的成员函数和类的对象访问。
  • protected成员:受保护的类成员,只能通过类的成员函数访问,无法通过类的对象访问。
  • private成员:私有的类成员,只能通过类的成员函数访问,无法通过类的对象访问。

注意这里访问的含义:对数据成员的访问是读写,对成员函数的访问则是函数调用。 对于数据的访问权限并没有被进一步拆分为只读和可读写,只读的访问效果需要基于const实现,实现只写的访问效果则需要考虑左值和右值特性,不在本文的讨论范围。

通过类的对象访问是指a.xa.show()这类语法。 通过类的成员函数访问是指在成员函数体内部对类的其它成员访问,因为它们都是同一个类的成员,相互之间理应是完全开放的,访问权限在此时通常是无意义的, 但是有一个例外:基类的private成员对派生类是完全隐藏的(无论采用哪种继承方式),派生类的成员函数无法对其进行访问。

我们目前没有考虑继承权限,只有在引入继承权限后才会体现出protectedprivate的区别,见下文。

关于访问权限的示例语法如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class 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
#include <iostream>

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->xthis->ythis->z)。
  • 在类的外部通过对象访问时,情况却是未必的:对public成员demo.x可以成功访问,但是对protectd成员demo.yprivate成员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
9
class 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
#include <iostream>

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
#include <iostream>

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成员的访问权限变为protectedprotected成员的访问权限保持不变;
  • private继承:将基类的public/protected成员的访问权限变为private

补充

友元

我们有时希望允许一个外部的函数或者类,在其中可以直接通过当前类的对象来访问所有内部成员,这对于运算符重载是很常见的需求。 C++允许我们使用friend将外部函数或类声明为当前类的友元,赋予它完整的访问权限。

例如将一个外部函数声明为友元,赋予这个函数完全的访问权限

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class 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
15
class 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
20
class 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
21
class 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

structclass这两个关键词都可以用于定义自定义类型,struct是延续自C语言的关键词,而class则是专属于C++的关键词。 在定义类型时,两者几乎所有的效果都是一样的,除了缺省访问权限限定词时的默认行为不一样:(这也是为了兼容C语言中的结构体语法)

  • struct定义时,默认的访问权限是public,默认的继承权限也是public
  • class定义时,默认的访问权限是private,默认的继承权限也是private

通过两个关键词定义得到的类实质上没有任何区别,但是从使用习惯的角度有如下的建议:

  • struct 建议用于定义简单的数据结构,仅包含公有的数据成员而没有函数成员,就像C语言中的结构体,例如数据传输对象或无行为的数据结构;
  • class 建议用于定义具有封装、继承和多态特性的复杂对象和类,具有成员函数,具有非公开成员。

突破权限约束

需要注意的是,这些权限信息只在编译期被用于语法检查,并不会体现在可执行程序中,在运行期这些信息已经被全部抹除了, 因此我们可以很容易地通过函数指针和偏移量等技巧来绕过权限控制,编译器无法对这种行为进行任何检查。 一个更简单粗暴的办法是通过宏定义将privateprotected全部变为public,然后重新编译。