Cpp学习18 第 14 章 类入门

铁名_IronName Lv5

14.1 — 面向对象编程简介

object-oriented programming
过程式编程中,重点在于创建“过程”(在 C++ 中称为函数),这些过程实现我们的程序逻辑。我们将数据对象传递给这些函数,这些函数对数据执行操作,然后可能会返回一个结果供调用者使用。

面向对象编程 (通常缩写为 OOP)的重点在于创建程序定义的数据类型,这些数据类型既包含属性,也包含一组定义明确的行为。OOP 中的“对象”一词指的是我们可以从这些类型实例化的对象。

14.2 — class 介绍

结构体最大的难点或许在于它们无法有效地记录和强制执行类不变式。在 9.6 课——断言和 static_assert 中,我们将不变式定义为“在某个组件执行期间必须为真的条件”。
在类类型(包括结构体、类和联合体)的上下文中, 类不变量是指对象在其整个生命周期内必须始终为真的条件,以使对象保持有效状态。如果一个对象的类不变量被违反,则该对象被认为处于无效状态 ,继续使用该对象可能会导致意外或未定义的行为。

与结构体类似, 类 class 是一种程序定义的复合类型,它可以有许多不同类型的成员变量。

C++标准库的大部分都是类。

14.3 — 成员函数

除了拥有成员变量之外,类类型(包括结构体、类和联合体)还可以拥有自己的函数!属于类类型的函数称为成员函数 。

成员函数在类类型定义内部声明。

成员变量和成员函数可以按任意顺序定义。
成员函数可以重载。

成员函数既可以用于结构体,也可以用于类。
但是,结构体应该避免定义构造函数成员函数,因为这样做会使它们成为非聚合体。

最佳实践

如果你的类类型没有数据成员,最好使用命名空间。

14.4 — const 类对象和 const 成员函数

可以使用 const 关键字将类类型对象(结构体、类和联合体)声明为 const。此类对象也必须在创建时进行初始化。

const 对象不能调用非常量成员函数

你可能会惊讶地发现,这段代码会导致编译错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include <iostream>

struct Date
{
int year {};
int month {};
int day {};

void print()
{
std::cout << year << '/' << month << '/' << day;
}
};

int main()
{
const Date today { 2020, 10, 14 }; // const

today.print(); // compile error: can't call non-const member function

return 0;
}

为了解决上述问题,我们需要将 print() 函数设为 const 成员函数。const 成员函数保证它不会修改对象,也不会调用任何非常量成员函数(因为它们可能会修改对象)。

1
2
3
4
5
6
7
8
9
10
11
struct Date
{
int year {};
int month {};
int day {};

void print() const // now a const member function
{
std::cout << year << '/' << month << '/' << day;
}
};

 print() 被设为常量成员函数,这意味着我们可以对常量对象(例如 today )调用它。
如果 const 成员函数试图修改数据成员或调用非常量成员函数,则会导致编译器错误。
const 成员函数可以像往常一样修改非成员(例如局部变量和函数参数),也可以调用非成员函数。const 关键字仅适用于成员。

最佳实践

不会(也永远不会)修改对象状态的成员函数应该设为 const,这样就可以在 const 对象和非 const 对象上调用它。

成员函数 const 和 non-const 重载

两个仅在常量性上有所不同的函数会被视为不同的函数。

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

struct Something
{
void print()
{
std::cout << "non-const\n";
}

void print() const
{
std::cout << "const\n";
}
};

int main()
{
Something s1{};
s1.print(); // calls print()

const Something s2{};
s2.print(); // calls print() const

return 0;
}

14.5 — 公共和私人成员及访问权限说明符

类类型的每个成员都有一个称为访问级别的属性,该属性决定了谁可以访问该成员。

C++ 有三种不同的访问级别: public(公共) 、 private(私有) 和 protected(受保护) 。在本课中,我们将介绍两种常用的访问级别:public 和 private。

结构体的成员默认都是公开的。

具有 公开 访问权限的成员称为 公共成员 。 公共成员属于一个类,其访问权限没有任何限制。就像我们开头提到的公园一样,任何人(只要在作用域内)都可以访问公共成员。
公共成员可以被同一类的其他成员访问。值得注意的是,公共成员也可以被公共代码访问,公共代码指的是存在于给定类类型成员 之外的 代码。 公共代码 的例子包括非成员函数以及其他类类型的成员。

类的成员默认是私有的。

具有 私有 访问权限的成员称为 私有成员 。 私有成员是指只能由同一类的其他成员访问的类类型成员。

最佳实践

考虑将私有数据成员命名为“m_”前缀,以便将其与局部变量、函数参数和成员函数的名称区分开来。

通过访问权限说明符设置访问级别

三种访问说明符: public: 、 private: 和 protected: 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Date
{
// Any members defined here would default to private

public: // here's our public access specifier

void print() const // public due to above public: specifier
{
// members can access other private members
std::cout << m_year << '/' << m_month << '/' << m_day;
}

private: // here's our private access specifier

int m_year { 2020 }; // private due to above private: specifier
int m_month { 14 }; // private due to above private: specifier
int m_day { 10 }; // private due to above private: specifier
};
最佳实践

类通常应该将成员变量设为私有(或受保护),成员函数设为公共。
结构体通常应避免使用访问说明符(所有成员默认均为公共成员)。

你已经知道成员函数可以直接访问(隐式对象的)私有成员。但是,由于访问级别是按类划分的,而不是按对象划分的,因此成员函数也可以直接访问作用域内任何其他同类型对象的私有成员。

14.6 — 访问函数

访问函数是一个简单的公共成员函数,其作用是检索或更改私有成员变量的值。
访问函数分为两种类型: getter 和 setter。getter(有时也称为访问器 )是公共成员函数  用于返回私有成员变量的值。setter(有时也称为修改器 )是公共成员函数,用于设置私有成员变量的值。
Getter 方法通常设置为 const,这样既可以用于 const 对象,也可以用于非常量对象。Setter 方法则应该设置为非常量,这样才能修改数据成员。

为了便于说明,让我们更新 Date 类,使其拥有完整的 getter 和 setter 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <iostream>

class Date
{
private:
int m_year { 2020 };
int m_month { 10 };
int m_day { 14 };

public:
void print()
{
std::cout << m_year << '/' << m_month << '/' << m_day << '\n';
}

int getYear() const { return m_year; } // getter for year
void setYear(int year) { m_year = year; } // setter for year

int getMonth() const { return m_month; } // getter for month
void setMonth(int month) { m_month = month; } // setter for month

int getDay() const { return m_day; } // getter for day
void setDay(int day) { m_day = day; } // setter for day
};

建议设置器使用“set”前缀。获取器可以使用“get”前缀,也可以不使用前缀。

Getter 方法应该提供对数据的“只读”访问。因此,最佳实践是它们应该按值返回(如果复制成员的成本不高),或者按常量左值引用返回(如果复制成员的成本很高)。

14.7 — 返回对数据成员的引用的成员函数

成员函数也可以通过(常量)左值引用返回数据成员。

返回引用的成员函数应该返回与所返回的数据成员类型相同的引用,以避免不必要的类型转换。

只有在创建右值对象的完整表达式中,才能安全地使用对右值对象成员的引用。
最好立即使用按引用返回的成员函数的返回值,以避免当隐式对象是右值时出现悬空引用的问题。

不要返回对私有数据成员的非 const 引用
常量成员函数不能返回对数据成员的非常量引用

14.8 — 数据隐藏(封装)的好处

data hiding (encapsulation)

在编程中, 数据隐藏 (也称为信息隐藏数据抽象 )是一种通过向用户隐藏(使其无法访问)程序定义的数据类型的实现来强制执行接口和实现分离的技术。

数据隐藏使类更易于使用,并降低复杂性。

要使用封装类,你不需要了解它的具体实现方式。你只需要理解它的接口:哪些成员函数是公开可用的,它们接受哪些参数,以及它们返回什么值。

数据隐藏使我们能够保持不变性

数据隐藏使我们能够更好地检测(和处理)错误。

数据隐藏使得在不破坏现有程序的情况下更改实现细节成为可能。

带有接口的类更容易调试。

如果一个成员只能通过单个成员函数进行修改,那么您只需在该函数上设置断点,即可观察每个调用者如何修改该值。这样就能更容易地确定是谁在修改它。

优先使用非成员函数而不是成员函数

  • 非成员函数不属于类的接口。因此,类的接口会更小更简洁,使类更容易理解。
  • 非成员函数强制执行封装,因为此类函数必须通过类的公共接口来工作。即使方便,也不会有人试图直接访问实现部分。
  • 在对类的实现进行更改时,无需考虑非成员函数(只要接口的更改方式不不兼容)。
  • 非成员函数通常更容易调试。
  • 包含应用程序特定数据和逻辑的非成员函数可以与类的可重用部分分离。

类成员声明的顺序

最佳实践

先声明公共成员,再声明受保护成员,最后声明私有成员。这样可以突出公共接口,弱化实现细节。

14.9 — 构造函数简介

constructors
一旦我们将任何成员变量设为私有(以隐藏数据),我们的类类型就不再是聚合类型(因为聚合类型不能有私有成员)。这意味着我们将无法再使用聚合初始化。

Constructors  构造函数

构造函数是一个特殊的成员函数,在创建非聚合类类型对象后会自动调用。

与普通成员函数不同,构造函数的命名有特定的规则:

  • 构造函数必须与类名相同(大小写必须一致)。对于模板类,此名称不包括模板参数。
  • 构造函数没有返回类型(甚至不是 void )。

构造函数不能是 const 的。

14.10 — 构造函数成员初始化列表

为了让构造函数初始化成员,我们使用成员初始化列表 (通常称为“成员初始化列表”)。不要将其与名称相似的“初始化器列表”混淆,后者用于使用值列表初始化聚合。

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

class Foo
{
private:
int m_x {};
int m_y {};

public:
Foo(int x, int y)
: m_x { x }, m_y { y } // here's our member initialization list
{
std::cout << "Foo(" << x << ", " << y << ") constructed\n";
}

void print() const
{
std::cout << "Foo(" << m_x << ", " << m_y << ")\n";
}
};

int main()
{
Foo foo{ 6, 7 };
foo.print();

return 0;
}
最佳实践

成员初始化列表中的成员变量应该按照它们在类中定义的顺序排列。

此外,最好避免使用其他成员的值来初始化成员(如果可能的话)。这样,即使初始化顺序出错,也不会造成影响,因为初始化值之间不存在依赖关系。

构造函数体

最佳实践

建议使用成员初始化列表来初始化成员,而不是在构造函数体中赋值。

14.11 — 默认构造函数和默认参数

默认构造函数是不接受任何参数的构造函数。通常情况下,它是在没有参数定义的情况下定义的构造函数。

1
2
Foo foo{}; // value initialization, calls Foo() default constructor
Foo foo2; // default initialization, calls Foo() default constructor

如果构造函数中的所有参数都有默认参数,则该构造函数是默认构造函数(因为它可以不带任何参数调用)。

因为构造函数是函数,所以它们可以被重载。一个类应该只有一个默认构造函数。如果提供了多个默认构造函数,编译器将无法区分应该使用哪个构造函数

如果一个非聚合类类型对象没有用户声明的构造函数,编译器将生成一个公共默认构造函数(以便该类可以进行值初始化或默认值初始化)。这个构造函数被称为隐式默认构造函数 。
隐式默认构造函数主要适用于没有数据成员的类。

我们可以指示编译器为我们生成一个默认构造函数。这个构造函数称为显式默认构造函数 ,可以使用 = default 语法生成

1
2
public:
Foo() = default;

(按理说都要自己写吧)

14.12 — 委托构造函数

不应直接从另一个函数体中调用构造函数。这样做要么会导致编译错误,要么会直接初始化一个临时对象。如果你确实想要一个临时对象,最好使用列表初始化(这样可以明确地表明你打算创建一个对象)。

构造函数可以将初始化任务委托(转移责任)给同一类类型的另一个构造函数。这个过程有时被称为构造函数链 ,而这样的构造函数被称为委托构造函数 。

1
2
3
4
5
6
7
8
9
10
11
public:
Employee(std::string_view name)
: Employee{ name, 0 } // delegate initialization to Employee(std::string_view, int) constructor
{
}

Employee(std::string_view name, int id)
: m_name{ name }, m_id { id } // actually initializes the members
{
std::cout << "Employee " << m_name << " created\n";
}

构造函数可以进行委托或初始化,但不能同时进行两者。

14.13 — 临时类对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void print(IntPair p)
{
std::cout << "(" << p.x() << ", " << p.y() << ")\n";
}

int main()
{
// Case 1: Pass variable
IntPair p { 3, 4 };
print(p);

// Case 2: Construct temporary IntPair and pass to function
print(IntPair { 5, 6 } );

// Case 3: Implicitly convert { 7, 8 } to a temporary Intpair and pass to function
print( { 7, 8 } );

return 0;
}

static_cast vs 显式实例化临时对象

最佳实践

一个简单的经验法则是:转换为基本类型时优先使用 static_cast ,转换为类类型时优先使用列表初始化的临时类型。

当满足以下任一条件时,创建临时对象时优先选择 static_cast :

  • 我们需要进行一次缩小范围的转换。
  • 我们希望非常清楚地表明,我们正在转换为一种类型,这将导致一些不同的行为(例如,将 char 转换为 int )。
  • 我们出于某种原因想要使用直接初始化(例如,为了避免列表构造函数优先级更高)。

当满足以下任一条件时,优先创建新对象(使用列表初始化),而不是创建临时对象:

  • 我们希望使用列表初始化(例如,为了防止范围缩小转换,或者因为我们需要调用列表构造函数)。
  • 我们需要向构造函数提供额外的参数以方便转换。

14.14 — 复制构造函数简介

复制构造函数是一种用于使用相同类型的现有对象初始化对象的构造函数。复制构造函数执行后,新创建的对象应该是作为初始化参数传入的对象的副本。

如果你没有为类提供复制构造函数,C++ 将为你创建一个公共的隐式复制构造函数 。
默认情况下,隐式复制构造函数会逐个成员进行初始化。这意味着每个成员都会使用作为初始化器传入的类的对应成员进行初始化。

我们还可以显式地定义自己的复制构造函数。

除非有特殊原因需要创建自定义构造函数,否则请优先使用隐式复制构造函数。

使用 = default 生成默认复制构造函数
使用 = delete 以防止复制

14.15 — 类初始化和复制省略

由于编译器可以自由地重写语句以进行优化,人们可能会想知道编译器是否可以优化掉不必要的复制,并将 Something s { Something{5} }; 视为我们一开始就写了 Something s { 5 } 。
答案是肯定的,这个过程称为 复制省略 。 复制省略是一种编译器优化技术,它允许编译器移除不必要的对象复制操作。换句话说,在编译器通常会调用复制构造函数的情况下,编译器可以重写代码,从而完全避免调用复制构造函数。当编译器优化掉对复制构造函数的调用时,我们称该构造函数已被省略 。

C++ 17 中的强制复制省略

14.16 — 转换构造函数和显式关键字

用于执行隐式转换的构造函数称为转换构造函数 。默认情况下,所有构造函数都是转换构造函数。

隐式转换只能应用一个用户自定义的转换

The explicit keyword  显式关键字

如果我们的 print(Dollars) 函数只能接受 Dollars 对象作为参数调用,而不能接受任何可以隐式转换为 Dollars 类型的值(尤其是像 int 这样的基本类型),那就更好了。这样可以降低意外出错的可能性。
为了解决此类问题,我们可以使用显式 explicit 关键字告诉编译器构造函数不应该用作转换构造函数。

让我们将前面示例中的 Dollars(int) 构造函数更新为显式构造函数:

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

class Dollars
{
private:
int m_dollars{};

public:
explicit Dollars(int d) // now explicit
: m_dollars{ d }
{
}

int getDollars() const { return m_dollars; }
};

void print(Dollars d)
{
std::cout << "$" << d.getDollars();
}

int main()
{
print(5); // compilation error because Dollars(int) is explicit

return 0;
}

由于编译器无法再使用 Dollars(int) 作为转换构造函数,因此无法将 5 转换为 Dollars 类型。因此,会产生编译错误。

显式构造函数仍然可以用于直接和直接列表初始化:

1
2
3
4
5
6
// Assume Dollars(int) is explicit
int main()
{
Dollars d1(5); // ok
Dollars d2{5}; // ok
}

static_cast 返回的是一个直接初始化的对象,因此在执行转换时会考虑显式构造函数:

1
print(static_cast<Dollars>(5)); // ok: static_cast will use explicit constructors
最佳实践

默认情况下,任何接受单个参数的构造函数都应 explicit 。如果类型之间的隐式转换在语义上等价且性能良好,则可以考虑将构造函数设为非显式。

不要显式地定义复制或移动构造函数,因为这些构造函数不会执行类型转换。

14.17 — Constexpr 聚合和类

Constexpr 成员函数

与非成员函数一样,成员函数也可以通过使用 constexpr 关键字使其成为 constexpr 函数。constexpr 成员函数可以在编译时或运行时求值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>

struct Pair
{
int m_x {};
int m_y {};

constexpr int greater() const // can evaluate at either compile-time or runtime
{
return (m_x > m_y ? m_x : m_y);
}
};

int main()
{
Pair p { 5, 6 };
std::cout << p.greater() << '\n'; // okay: p.greater() evaluates at runtime

constexpr int g { p.greater() }; // compile error: p not constexpr
std::cout << g << '\n';

return 0;
}

Constexpr 聚合

如果我们需要 p 是 constexpr 类型,那就把它设为 constexpr 类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>

struct Pair // Pair is an aggregate
{
int m_x {};
int m_y {};

constexpr int greater() const
{
return (m_x > m_y ? m_x : m_y);
}
};

int main()
{
constexpr Pair p { 5, 6 }; // now constexpr
std::cout << p.greater() << '\n'; // p.greater() evaluates at runtime or compile-time

constexpr int g { p.greater() }; // p.greater() must evaluate at compile-time
std::cout << g << '\n';

return 0;
}

因为 p 是一个 constexpr 类型,而 greater() 是一个 constexpr 成员函数,所以 p.greater() 是一个常量表达式,可以在只允许使用常量表达式的地方使用。

constexpr 类对象和 constexpr 构造函数

如果你希望你的类能够在编译时被求值,请将你的成员函数和构造函数设为 constexpr。

对于非 const 对象,可能需要 const 成员。


constexpr 非常量成员函数可以更改数据成员
返回常量引用(或指针)的 constexpr 函数
(已晕倒。我以后用到会回来看的。)

在上一个测验题中,为什么最好将 getFraction() 和 print() 作为非成员函数?

使用非成员版本的 getFraction() 函数,我们可以一步完成 Fraction 对象的定义和初始化。而成员版本则需要两步,因为我们必须先创建一个对象,然后再调用该对象的成员函数。此外,它还会打印一些应用程序特定的文本提示供用户参考。
通过将 print() 移至非成员类型(并使用访问函数来访问成员),我们将其从类的接口中移除,从而使类的核心功能更加简洁。这也意味着,如果类的实现发生更改,只要接口保持不变,我们就无需考虑是否需要更新 print() 函数(因为我们知道它无法直接访问任何数据成员)。

  • 标题: Cpp学习18 第 14 章 类入门
  • 作者: 铁名_IronName
  • 创建于 : 2026-08-11 13:24:12
  • 更新于 : 2026-08-11 19:21:31
  • 链接: https://blog.ironname.top/2026/Cpp/Cpp学习18/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论