重要的事情说三遍:
类
类是数据结构的扩展概念:与数据结构类似,类可以包含数据成员,但它们也可以包含函数成员。
一个对象是类的实例。从变量的角度来看,类是类型,而对象是变量。
使用关键字class
或struct
定义类,语法如下:
class class_name {
access_specifier_1:
member1;
access_specifier_2:
member2;
...
} object_names;
其中class_name
是类的有效标识符,object_names
是该类对象的可选名称列表。声明体可以包含成员,可以是数据或函数声明,并且可以有访问说明符。
类的格式与普通数据结构相同,区别在于它们还可以包含函数,并且具有访问说明符。访问说明符是以下三个关键字之一:private
、public
或protected
。这些说明符修改后续成员的访问权限:
private
类成员只能从同一类的其他成员(或其“朋友”)中访问。protected
类成员可以从同一类的其他成员(或其“朋友”)中访问,也可以从其派生类的成员中访问。public
类成员可以从对象可见的任何地方访问。
默认情况下,使用class
关键字声明的类的所有成员都具有私有访问权限。因此,任何在其他访问说明符之前声明的成员都自动具有私有访问权限。例如:
class Rectangle {
int width, height;
public:
void set_values(int, int);
int area(void);
} rect;
声明了一个名为Rectangle
的类(即类型)和一个名为rect
的该类对象(即变量)。此类包含四个成员:两个类型为int
的数据成员(成员width
和成员height
),具有私有访问权限(因为私有是默认访问级别)和两个具有公共访问权限的成员函数:set_values
和area
,目前我们只包括它们的声明,但没有定义。
注意类名和对象名之间的区别:在上例中,Rectangle
是类名(即类型),而rect
是Rectangle
类型的对象。这与以下声明中的int
和a
的关系相同:
int a;
其中int
是类型名(类),a
是变量名(对象)。
在声明了Rectangle
和rect
之后,可以像访问普通函数或变量一样访问对象rect
的任何公共成员,只需在对象名和成员名之间插入一个点(.
)。这与访问普通数据结构的成员的语法相同。例如:
rect.set_values(3, 4);
int myarea = rect.area();
rect
的唯一不能从类外部访问的成员是width
和height
,因为它们具有私有访问权限,只能从该类的其他成员中引用。
这是完整的Rectangle类示例:
// classes example
#include <iostream>
using namespace std;
class Rectangle {
int width, height;
public:
void set_values(int, int);
int area() { return width * height; }
};
void Rectangle::set_values(int x, int y) {
width = x;
height = y;
}
int main() {
Rectangle rect;
rect.set_values(3, 4);
cout << "area: " << rect.area();
return 0;
}
这个例子重新引入了之前章节中与命名空间相关的作用域运算符(::
,两个冒号)。这里用于在类外部定义函数set_values
的成员。
注意,成员函数area
的定义直接包含在Rectangle
类的定义中,因为它非常简单。相反,set_values
只在类内声明了其原型,但定义在类外部。在这个外部定义中,使用作用域运算符(::
)来指定定义的函数是类Rectangle
的成员,而不是普通的非成员函数。
作用域运算符(::
)指定正在定义的成员所属的类,赋予与直接包含在类定义中相同的作用域属性。例如,上例中的函数set_values
可以访问类Rectangle
的私有成员变量width
和height
,因此只能从该类的其他成员(如此函数)访问。
在类定义中完全定义成员函数和仅在类中声明其声明并稍后在类外部定义的唯一区别在于,第一种情况下,编译器会自动将该函数视为内联成员函数,而在第二种情况下,它是普通的(非内联)类成员函数。这不会导致行为上的差异,只会影响编译器的优化。
成员width
和height
具有私有访问权限(记住,如果没有其他说明,使用class
关键字定义的类的所有成员都具有私有访问权限)。通过将它们声明为私有,从类外部访问它们是不允许的。这是有道理的,因为我们已经定义了一个成员函数来为对象内的这些成员设置值:成员函数set_values
。因此,程序的其余部分不需要直接访问它们。或许在这个简单的例子中,很难看到限制访问这些变量的用处,但在更大的项目中,不让值以意想不到的方式(从对象的角度来看)被修改可能非常重要。
类的最重要的属性是它是一种类型,因此我们可以声明多个对象。例如,继续前面的Rectangle
类示例,我们可以除了对象rect
外,还声明对象rectb
:
// example: one class, two objects
#include <iostream>
using namespace std;
class Rectangle {
int width, height;
public:
void set_values(int, int);
int area() { return width * height; }
};
void Rectangle::set_values(int x, int y) {
width = x;
height = y;
}
int main() {
Rectangle rect, rectb;
rect.set_values(3, 4);
rectb.set_values(5, 6);
cout << "rect area: " << rect.area() << endl;
cout << "rectb area: " << rectb.area() << endl;
return 0;
}
在这个特定例子中,类(对象的类型)是Rectangle
,它有两个实例(即对象):rect
和rectb
。它们每个都有自己的成员变量和成员函数。
注意,调用rect.area()
的结果与调用rectb.area()
的结果不同。这是因为每个Rectangle
类的对象都有自己的变量width
和height
,因为它们在某种程度上也有自己的成员函数set_value
和area
,它们操作对象的成员变量。
类允许使用面向对象的编程范式:数据和函数都是对象的成员,减少了将处理程序或其他状态变量作为参数传递给函数的需求,因为它们是调用成员的对象的一部分。注意,在调用rect.area
或rectb.area
时没有传递任何参数。那些成员函数直接使用它们各自对象的成员数据。
构造函数
如果在前面的示例中调用成员函数area
之前没有调用set_values
,会发生什么?结果是不确定的,因为成员width
和height
从未被赋值。
为了避免这种情况,类可以包含一个特殊函数称为构造函数,它在每次创建此类的新对象时自动调用,允许类初始化成员变量或分配存储空间。
构造函数的声明与常规成员函数相同,但名称与类名匹配且没有返回类型,甚至没有void
。
可以通过实现构造函数轻松改进上述的Rectangle
类:
// example: class constructor
#include <iostream>
using namespace std;
class Rectangle {
int width, height;
public:
Rectangle(int, int);
int area() { return (width * height); }
};
Rectangle::Rectangle(int a, int b) {
width = a;
height = b;
}
int main() {
Rectangle rect(3, 4);
Rectangle rectb(5, 6);
cout << "rect area: " << rect.area() << endl;
cout << "rectb area: " << rectb.area() << endl;
return 0;
}
这个例子的结果与前一个例子相同。但现在,Rectangle
类没有成员函数set_values
,而是有一个执行类似操作的构造函数:它用传递给它的参数初始化width
和height
的值。
注意在创建此类对象时如何将这些参数传递给构造函数:
Rectangle rect(3, 4);
Rectangle rectb(5, 6);
构造函数不能像常规成员函数那样显式调用。当创建该类的新对象时,它们只执行一次。
注意,构造函数的原型声明(在类中)和后来的构造函数定义都没有返回值,甚至没有void
:构造函数从不返回值,它们只是初始化对象。
构造函数的重载
像其他函数一样,构造函数也可以通过不同版本的重载,接受不同的参数:具有不同数量的参数和/或不同类型的参数。编译器将自动调用参数与参数匹配的构造函数:
// overloading class constructors
#include <iostream>
using namespace std;
class Rectangle {
int width, height;
public:
Rectangle();
Rectangle(int, int);
int area(void) { return (width * height); }
};
Rectangle::Rectangle() {
width = 5;
height = 5;
}
Rectangle::Rectangle(int a, int b) {
width = a;
height = b;
}
int main() {
Rectangle rect(3, 4);
Rectangle rectb;
cout << "rect area: " << rect.area() << endl;
cout << "rectb area: " << rectb.area() << endl;
return 0;
}
在上面的例子中,构造了两个Rectangle
类的对象:rect
和rectb
。rect
是用两个参数构造的,就像前面的例子一样。
但这个例子还引入了一种特殊的构造函数:默认构造函数。默认构造函数是指不带参数的构造函数,它是特殊的,因为当对象被声明但没有使用任何参数初始化时调用。在上面的例子中,默认构造函数被调用用于rectb
。注意rectb
甚至没有用空括号构造——事实上,不能用空括号调用默认构造函数:
Rectangle rectb; // ok, default constructor called
Rectangle rectc(); // oops, default constructor NOT called
这是因为空括号会使rectc
成为函数声明而不是对象声明:它将是一个不接受任何参数并返回Rectangle
类型值的函数。
统一初始化
使用括号将参数括起来的方式调用构造函数称为函数形式。但构造函数也可以使用其他语法调用:
首先,带有单个参数的构造函数可以使用变量初始化语法(等号后跟参数)调用:
class_name object_name = initialization_value;
最近,C++引入了使用统一初始化调用构造函数的可能性,这与函数形式基本相同,但使用大括号({}
)而不是括号(()
):
class_name object_name { value, value, value, ... }
可选地,最后一种语法可以在大括号前包含一个等号。
这是一个示例,其中四种方式构造类对象,其构造函数接受一个参数:
// classes and uniform initialization
#include <iostream>
using namespace std;
class Circle {
double radius;
public:
Circle(double r) { radius = r; }
double circum() { return 2 * radius * 3.14159265; }
};
int main() {
Circle foo(10.0); // functional form
Circle bar = 20.0; // assignment init.
Circle baz{30.0}; // uniform init.
Circle qux = {40.0}; // POD-like
cout << "foo's circumference: " << foo.circum() << '\n';
return 0;
}
与函数形式相比,统一初始化的一个优点是大括号不会被误认为是函数声明,因此可以用于显式调用默认构造函数:
Rectangle rectb; // default constructor called
Rectangle rectc(); // function declaration (default constructor NOT called)
Rectangle rectd{}; // default constructor called
选择调用构造函数的语法在很大程度上是风格问题。大多数现有代码目前使用函数形式,一些新的风格指南建议选择统一初始化,即使它也有偏爱initializer_list
类型的潜在缺陷。
构造函数中的成员初始化
当构造函数用于初始化其他成员时,这些其他成员可以直接初始化,而不需要在其主体中使用语句。这是通过在构造函数的主体之前插入一个冒号(:
)和一个类成员初始化列表来完成的。例如,考虑以下声明的类:
class Rectangle {
int width, height;
public:
Rectangle(int, int);
int area() { return width * height; }
};
这个类的构造函数可以像往常一样定义:
Rectangle::Rectangle(int x, int y) { width = x; height = y; }
但它也可以使用成员初始化定义:
Rectangle::Rectangle(int x, int y) : width(x) { height = y; }
甚至:
Rectangle::Rectangle(int x, int y) : width(x), height(y) { }
注意在这种情况下,构造函数除了初始化其成员外不执行任何其他操作,因此它有一个空的函数主体。
对于基本类型的成员,上述构造函数的定义方式没有区别,因为它们默认不会初始化,但对于成员对象(类型是类的成员),如果它们没有在冒号后初始化,它们将被默认构造。如果一个类的成员对象没有在构造函数初始化列表中显式初始化,那么它会被默认构造函数初始化(如果该对象的类有默认构造函数)。
默认构造类的所有成员可能总是方便的:在某些情况下,这是浪费(当成员在构造函数中以其他方式重新初始化时),但在某些情况下,默认构造甚至是不可能的(当类没有默认构造函数时)。在这些情况下,成员应在成员初始化列表中初始化。例如:
// member initialization
#include <iostream>
using namespace std;
class Circle {
double radius;
public:
Circle(double r) : radius(r) { }
double area() { return radius * radius * 3.14159265; }
};
class Cylinder {
Circle base;
double height;
public:
// 去掉base(r)会导致编译器报错,因为Circle没有默认构造函数
Cylinder(double r, double h) : base(r), height(h) {}
double volume() { return base.area() * height; }
};
int main() {
Cylinder foo(10, 20);
cout << "foo's volume: " << foo.volume() << '\n';
return 0;
}
在这个例子中,类Cylinder
有一个成员对象,其类型是另一个类(base
的类型是Circle
)。因为Circle
类的对象只能用一个参数构造,Cylinder
的构造函数需要调用base
的构造函数,而唯一的方法是在成员初始化列表中。
这些初始化也可以使用统一初始化语法,使用大括号{}
而不是括号()
:
Cylinder::Cylinder(double r, double h) : base{r}, height{h} { }
类的指针
对象也可以通过指针指向:一旦声明,类成为一个有效类型,因此可以用作指针指向的类型。例如:
Rectangle *prect;
这是一个指向类Rectangle
对象的指针。
类似于普通数据结构,可以使用箭头运算符(->
)直接从指针访问对象的成员。这里是一个包含一些可能组合的示例:
// pointer to classes example
#include <iostream>
using namespace std;
class Rectangle {
int width, height;
public:
Rectangle(int x, int y) : width(x), height(y) {}
int area(void) { return width * height; }
};
int main() {
Rectangle obj(3, 4);
Rectangle *foo, *bar, *baz;
foo = &obj;
bar = new Rectangle(5, 6);
baz = new Rectangle[2]{{2, 5}, {3, 6}};
cout << "obj's area: " << obj.area() << '\n';
cout << "*foo's area: " << foo->area() << '\n';
cout << "*bar's area: " << bar->area() << '\n';
cout << "baz[0]'s area:" << baz[0].area() << '\n';
cout << "baz[1]'s area:" << baz[1].area() << '\n';
delete bar;
delete[] baz;
return 0;
}
这个例子使用了几个运算符来操作对象和指针(运算符*
、&
、.
、->
、[]
)。它们可以解释为:
表达式 | 可以解释为 |
---|---|
*x | 指向x 的对象 |
&x | x 的地址 |
x.y | 对象x 的成员y |
x->y | 指向对象x 的成员y |
(*x).y | 指向对象x 的成员y (等同于前一个) |
x[0] | 指向x 的第一个对象 |
x[1] | 指向x 的第二个对象 |
x[n] | 指向x 的第n+1 个对象 |
大多数这些表达式在之前的章节中已经介绍。尤其是,关于数组的章节介绍了偏移运算符([]
),关于普通数据结构的章节介绍了箭头运算符(->
)。
使用 struct 和 union 定义的类
类不仅可以用关键字class
定义,还可以用关键字struct
和union
定义。
一般用来声明普通数据结构的关键字struct
也可以用来声明具有成员函数的类,语法与使用关键字class
相同。两者之间的唯一区别是,用关键字struct
声明的类的成员默认具有公共访问权限,而用关键字class
声明的类的成员默认具有私有访问权限。在这个上下文中,除了访问权限外,两者在其他所有方面都是等价的。
相反,联合体的概念与使用struct
和class
声明的类不同,因为联合体一次只存储一个数据成员,但它们也是类,因此也可以包含成员函数。联合类的默认访问权限是public
。