类和对象
什么是类?
面向对象软件的基本构建块。
一个 class
定义了一个数据类型,很像 C 语言中的 struct
。从计算机科学的角度来看,一个类型既包含一组状态,也包含一组在这些状态之间转换的操作。因此,int
是一个类型,因为它既有一组状态,也有像 i + j
或 i++
等操作。以同样的方式,一个 class
提供了一组(通常是 public
的)操作,以及一组(通常是非 public
的)数据位,代表该类型实例可以拥有的抽象值。
你可以想象 int
是一个拥有名为 operator++
等成员函数的 class
。(int
并不是真正的 class
,但基本类比是:一个 class
是一个类型,很像 int
是一个类型。)
注意:C 程序员可以把 class
想象成一个成员默认为 private
的 C struct
。但如果这就是你对 class
的全部理解,那么你可能需要经历一次个人范式转变。
什么是对象?
一块带有相关语义的存储区域。
在声明 int i;
之后,我们说“i
是 int
类型的一个对象”。在面向对象/C++ 中,“对象”通常指“一个类的实例”。因此,一个类定义了许多对象(实例)的行为。
什么时候接口是“好的”?
当它提供软件块的简化视图,并且以用户词汇表达时(其中“块”通常是一个类或一个紧密相关的类组,而“用户”是另一个开发者而不是最终客户)。
- “简化视图”意味着不必要的细节被故意隐藏。这降低了用户的缺陷率。
- “用户词汇”意味着用户不需要学习一套新的词语和概念。这降低了用户的学习曲线。
什么是封装?
防止未经授权地访问某些信息或功能。
节省资金的关键在于将软件的某个“块”中易变的部分与稳定部分分离。封装在“块”周围设置了一个防火墙,阻止其他“块”访问易变部分;其他“块”只能访问稳定部分。这可以防止在易变部分改变时(当它们改变时!)其他“块”崩溃。在面向对象软件的上下文中,“块”通常是一个类或一个紧密相关的类组。
“易变部分”是实现细节。如果块是一个单一的类,易变部分通常使用private
和/或 protected
关键字进行封装。如果块是一个紧密相关的类组,封装可以用于拒绝访问该组中的整个类。继承也可以作为一种封装形式。
“稳定部分”是接口。一个好的接口提供了一个用用户词汇表达的简化视图,并且是从外向内设计的(这里的“用户”指另一个开发者,而不是购买已完成应用程序的最终用户)。如果块是一个单一的类,接口就是类的 public
成员函数和friend
函数。如果块是一个紧密相关的类组,接口可以包含块中的几个类。
设计一个清晰的接口并将该接口与其实现分离只是允许用户使用该接口。但封装(将其“置于胶囊中”)实现强制用户使用该接口。
C++ 如何帮助平衡安全性与可用性?
在 C 语言中,封装是通过在编译单元或模块中将事物设为 static
来实现的。这阻止了另一个模块访问 static
的内容。(顺便说一句,C++ 中文件作用域的 static
数据现在已弃用:不要那样做。)
不幸的是,这种方法不支持数据的多个实例,因为没有直接支持创建模块 static
数据的多个实例。如果 C 语言中需要多个实例,程序员通常会使用 struct
。但不幸的是,C 语言的 struct
不支持封装。这加剧了安全性(信息隐藏)和可用性(多个实例)之间的权衡。
在 C++ 中,您可以通过类同时拥有多个实例和封装。类的 public
部分包含类的接口,通常由类的 public
成员函数和其friend
函数组成。类的private
和/或 protected
部分包含类的实现,通常是数据所在的位置。
最终结果就像一个“封装的 struct
”。这减少了安全性(信息隐藏)和可用性(多个实例)之间的权衡。
如何防止其他程序员通过查看我的类的 private
部分来违反封装?
不值得费心——封装是为代码服务的,而不是为人服务的。
程序员看到你的类的private
和/或 protected
部分并不违反封装,只要他们不编写以某种方式依赖于他们所看到内容的任何代码。换句话说,封装并不能阻止人了解类的内部;它阻止他们编写的代码依赖于类的内部。你的公司不必为维护你耳朵之间的灰质而支付“维护成本”;但它确实必须为维护你指尖输出的代码而支付维护成本。你作为一个人所知道的知识并不会增加维护成本,前提是你编写的代码依赖于接口而不是实现。
此外,这很少成为问题。我认识的程序员中,没有人故意试图访问类的 private
部分。“在这种情况下,我的建议是改变程序员,而不是代码”[James Kanze;经许可使用]。
一个方法可以直接访问其类另一个实例的非 public
成员吗?
是的。
名称 this
并非特殊。访问权限的授予或拒绝是基于引用/指针/对象的类,而不是基于引用/指针/对象的名称。(详细信息见下文。)
C++ 允许一个类的方法和友元访问其所有对象的非 public
部分,而不仅仅是 this
对象,这乍一看似乎削弱了封装。然而,事实恰恰相反:这条规则维护了封装。原因如下。
如果没有这条规则,大多数非 public
成员将需要一个 public
get 方法,因为许多类至少有一个方法或友元,它接受一个显式参数(即,一个不称为 this
的参数),该参数是其自身的类类型。
嗯?(你问)。让我们抛开那些行话,举个例子
考虑赋值运算符 Foo::operator=(const Foo& x)
。这个赋值运算符可能会根据右侧参数 x
中的数据成员来改变左侧参数 *this
中的数据成员。如果没有这里讨论的 C++ 规则,该赋值运算符访问 x
的非 public
成员的唯一方法就是让类 Foo
为每个非 public
数据提供一个 public
get 方法。那将非常糟糕。(注意:“非常糟糕”是一个精确、复杂、技术性的术语;我是在 4 月 1 日写的这篇文章。)
赋值运算符不是唯一一个如果没有这条规则就会削弱封装的。以下是其他一些(部分!)列表:
- 拷贝构造函数。
- 比较运算符:
==
、!=
、<=
、<
、>=
、>
。 - 二元算术运算符:
x+y
、x-y
、x*y
、x/y
、x%y
。 - 二元位运算符:
x^y
、x&y
、x|y
。 - 接受类实例作为参数的静态方法。
- 创建/操作类实例的静态方法。
- 等等。
结论:没有这条有益的规则,封装就会被撕裂:大多数类的非 public
成员最终都会有一个 public
get 方法。
细则: 还有一条与此相关的规则:派生类的方法和友元可以访问其自身任何对象(其类或其类的任何派生类的任何对象)的 protected
基类成员,但不能访问其他对象的。由于这令人绝望地晦涩,这里举个例子:假设类 D1
和 D2
直接继承自类 B
,并且基类 B
有 protected
成员 x
。编译器将允许 D1
的成员和友元直接访问它们已知至少是 D1
的任何对象的 x
成员,例如通过 D1*
指针、D1&
引用、D1
对象等。然而,如果 D1
成员或友元试图直接访问它不知道至少是 D1
的任何对象的 x
成员,例如通过 B*
指针、B&
引用、B
对象、D2*
指针、D2&
引用、D2
对象等,编译器将给出编译时错误。通过(不完美!!)类比,你可以掏自己的口袋,但你不能掏你父亲的口袋,也不能掏你兄弟的口袋。
封装是安全装置吗?
不是。
封装 !=
安全。
封装防止错误,而非间谍活动。
关键字 struct
和 class
有什么区别?
一个 struct
的成员和基类默认为 public
,而 class
中,它们默认为 private
。注意:你应该显式地将你的基类声明为 public
、private
或 protected
,而不是依赖于默认设置。
struct
和 class
在其他方面功能上是等价的。
够了那些干净利落的技术谈论。从情感上讲,大多数开发者对 class
和 struct
有着强烈的区分。一个 struct
简单地感觉像一堆开放的位,几乎没有封装或功能。一个 class
感觉像一个有智慧服务、强大封装屏障和良好定义接口的社会中活跃而负责任的成员。既然这是大多数人已经拥有的内涵,如果你的类方法很少且数据是 public
的(在设计良好的系统中确实存在!),那么你可能应该使用 struct
关键字,否则你可能应该使用 class
关键字。
如何定义类内常量?
如果你想要一个可以在编译时常量表达式中使用的常量,比如作为数组的边界,如果你的编译器支持 C++11 的 constexpr
特性,就使用它,否则你有另外两种选择。
class X {
constexpr int c1 = 42; // preferred
static const int c2 = 7;
enum { c3 = 19 };
array<char,c1> v1;
array<char,c2> v2;
array<char,c3> v3;
// ...
};
如果常量不需要在编译时常量表达式中使用,你会有更大的灵活性。
class Z {
static char* p; // initialize in definition
const int i; // initialize in constructor
public:
Z(int ii) :i(ii) { }
};
char* Z::p = "hello, there";
如果(并且仅当)它有一个类外定义时,你可以将引用绑定到静态数据成员,或者获取其地址。
class AE {
// ...
public:
static const int c6 = 7;
static const int c7 = 31;
};
const int AE::c7; // definition
void byref(const int&);
int f()
{
byref(AE::c6); // error: c6 not an lvalue
byref(AE::c7); // ok
const int* p1 = &AE::c6; // error: c6 not an lvalue
const int* p2 = &AE::c7; // ok
// ...
}
为什么我必须把数据放在我的类声明中?
你不需要。如果你不希望接口中有数据,就不要把它放在定义接口的类中。而是把它放在派生类中。参见为什么我的编译时间那么长?。
有时,你确实希望在类中拥有表示数据。考虑类 complex
template<class Scalar> class complex {
public:
complex() : re(0), im(0) { }
complex(Scalar r) : re(r), im(0) { }
complex(Scalar r, Scalar i) : re(r), im(i) { }
// ...
complex& operator+=(const complex& a)
{ re+=a.re; im+=a.im; return *this; }
// ...
private:
Scalar re, im;
};
这种类型旨在像内置类型一样使用,并且在声明中需要表示,以便能够创建真正的局部对象(即分配在栈上而非堆上的对象),并确保简单操作的正确内联。真正的局部对象和内联对于使 complex
的性能接近于内置复数类型的语言所提供的性能是必要的。
C++ 对象在内存中是如何布局的?
与 C 语言类似,C++ 不定义布局,只定义必须满足的语义约束。因此,不同的实现方式有所不同。一本虽已过时且不描述任何当前 C++ 实现的书提供了很好的解释:《带注释的 C++ 参考手册》(通常称为 ARM)。它包含关键布局示例的图示。在《TC++PL3》的第 2 章中有一个非常简短的解释。
基本上,C++ 通过简单地连接子对象来构造对象。因此
struct A { int a,b; };
由两个相邻的 int
表示,并且
struct B : A { int c; };
由一个 A
后跟一个 int
表示;也就是说,由三个相邻的 int
表示。
虚函数通常通过向每个带有虚函数的类对象添加一个指针(“vptr”)来实现。这个指针指向相应的函数表(“vtbl”)。每个类都有自己的 vtbl,由该类的所有对象共享。
为什么空类的大小不为零?
为了确保两个不同对象的地址不同。出于同样的原因,new
总是返回指向不同对象的指针。考虑
class Empty { };
void f()
{
Empty a, b;
if (&a == &b) cout << "impossible: report error to compiler supplier";
Empty* p1 = new Empty;
Empty* p2 = new Empty;
if (p1 == p2) cout << "impossible: report error to compiler supplier";
}
有一个有趣的规则,即空基类不必用单独的字节表示
struct X : Empty {
int a;
// ...
};
void f(X* p)
{
void* p1 = p;
void* p2 = &p->a;
if (p1 == p2) cout << "nice: good optimizer";
}
这种优化是安全的,而且非常有用。它允许程序员使用空类来表示非常简单的概念,而无需额外开销。一些当前的编译器提供了这种“空基类优化”。
此外,“空基类优化”不再是可选优化,而是 C++11 中对类布局的强制要求。如果您的编译器没有正确实现,就去抱怨您的编译器供应商吧。