C++初学者指南-3.自定义类型(第一部分)-类和基本自定义类型
文章目录
- C++初学者指南-3.自定义类型(第一部分)-类和基本自定义类型
- 1.类型种类(简化)
- 2.为什么选择自定义类型?
- 单向计数器
- 提升序列
- 3.限制成员访问
- 成员函数
- 公共(public) vs. 私有(private)的可见性
- const成员函数
- 成员声明 vs. 定义
- 操作符成员函数
- 3.初始化
- 成员初始化
- 构造函数
- 默认构造函数与自定义构造函数
- 显式构造函数 ↔ 隐式转换
- 构造函数委托
- 最令人困扰的解析
- 4.设计,约定和风格
- 接口中的数据类型
- 成员和非成员
- 避免使用Setter/Getter对!
- 命名
- 使用专用类型!
- 5.示例实现
- 示例1:单向的计数器
- 示例2:递增序列
1.类型种类(简化)
基本类型 | void, bool, char, int, double, … |
简单聚合 | 主要目的:数据分组 聚合:可能包含一个或多个基本类型或其他兼容的聚合类型 无法控制组成类型的相互作用。 简单,如果只有(编译器生成)默认构造函数/析构函数/拷贝构造函数/赋值函数 标准的内存布局(所有成员按声明顺序连续排列),如果所有成员都具有相同的访问控制(例如全部是公共的) |
更复杂自定义类型 | 主要目的:确保正确性和安全性保证。 自定义不变量和对成员相互作用的控制。 限制成员访问。 成员函数 用户定义的构造函数/成员初始化。 用户自定义析构函数/拷贝构造函数/赋值函数。 可以是多态的(包含虚成员函数)。 |
2.为什么选择自定义类型?
正确性保证
- 不变量 = 从不改变的行为和/或数据属性
- 通过控制/限制对数据成员的访问来避免数据损坏
- 使用专用类型来限制函数的输入/输出值
可复用的抽象
- 隐藏低级实现细节的易于使用的接口
- 不受内部实现变化影响的稳定接口
- 可重用的常用功能的抽象(例如,动态数组)
资源管理
也被称为 RAII(资源获取即初始化)
- 在构造对象时获取一些资源(内存、文件句柄、连接等)
- 当对象被销毁时释放/清理资源(释放内存、关闭连接等)
单向计数器
- 存储整数
- 初始化为0
- 不变性:计数只能增加(不能减少或重置)
monotonous_counter c;
cout << c.reading(); // prints 0
c.increment();
cout << c.reading(); // prints 1
c.increment();
c.increment();
cout << c.reading(); // prints 3
简单的聚合类型不能保证:
struct frail_counter {
int count;
};
frail_counter c;
cout << c.count; // any value
c.count++;
c.count = 11;
- 整数成员未自动初始化为 0
- 可以自由修改聚合类型的任何整数成员
- ⇒仅使用整数没有任何优势
提升序列
- 应该存储整数
- 不变性:元素数量只能增加,即只能插入新元素,而不能移除它们
- 不变性:元素必须始终按升序排序
简单的聚合不能保证:
struct chaotic_sequence {
std::vector<int> nums;
};
chaotic_sequence s;
s.nums.push_back(8); 8
s.nums.push_back(1); 8 1
s.nums.push_back(4); 8 1 4
s.nums.pop_back(4); 8 1
可能违反要求
- 数字不一定按升序排序
- 我们可以随意操作数字,比如删除数字等
- 与使用普通std::vector相比没有什么优势
3.限制成员访问
成员函数
class monotonous_counter {
int count_; // ← 数据成员
…
void increment () { // ← 成员函数
++count_;
}
};
class ascending_sequence {
std::vector<int> seq_; // ← 数据成员
…
void insert (int x) { // ← 成员变量
// 在正确的位置将 x 插入到 nums 中
}
};
成员函数可用于
- 操作或查询数据成员
- 控制/限制对数据成员的访问
- 隐藏低级实现细节
- 确保正确性:保持/保证不变量
- 确保清晰度:为不同类型的用户设计良好结构的接口
- 确保稳定性:内部数据表示(大部分)独立于接口
- 避免重复/模板:避免重复/样板:对于潜在的复杂操作,只需调用一次即可
公共(public) vs. 私有(private)的可见性
私有成员只能通过成员函数访问:
class ascending_sequence {
private:
std::vector<int> seq_;
// … more private members
public:
void insert (int x) { … }
auto size () const { return seq_.size(); }
// … more public members
};
int main () {
ascending_sequence s;
s.insert(8); // 'insert' 是公共的
auto n = s.size(); // 'size' 是公共的
auto i = s.seq_[0]; // 编译错误: 'seq_' 是私有的
auto m = s.seq_.size(); // 编译错误
s.seq_.push_back(1); // 编译错误
}
struct 与 class – 主要区别在于默认可见性:
关键字 | 通常用于 |
---|---|
struct | 公共数据的简单聚合 |
class | 私有数据、成员函数、不变量…… |
const成员函数
只有带 const 修饰的成员函数才能被 const 对象调用:
class ascending_sequence {
std::vector<int> seq_;
public: …
void insert { … }
auto size () const { return seq_.size(); }
};
int main () {
ascending_sequence s;
s.insert(88); // s不是const的
auto const& cs = s;
cs.insert(5); // 编译错误: 'insert' 不是const的
}
接受常量(引用)参数的函数不仅承诺不修改它,这个承诺还将被编译器检查并强制执行。
void foo (ascending_sequence const& s) {
// 's' is const reference ^^^^^
auto n = s.size(); // 'size' 是 const
s.insert(5); // 编译错误: 'insert' 不是 const
}
const成员函数内部的成员是const
class monotonous_counter {
int count_;
public: …
int reading () const {
// 编译错误: count_ 是 const:
count_ += 2;
return count_;
}
};
class ascending_sequence {
std::vector<int> seq_;
public: …
auto size () const { // 'seq_' 是 const
// 编译错误: 调用非const的'push_back'
seq_.push_back(0);
// vector的成员 'size()' 是const的
return seq_.size();
}
};
成员函数可以通过const进行重载
如果一个成员函数是const-限定的,另一个不是,它们可以有相同的名称(和参数列表)。这样可以清楚地区分只读访问和读写操作。
class interpolation { …
int t_;
…
public:
…
// 读/写函数对:
void threshold (int t) { if (t > 0) t_ = t; }
int threshold () const { return t_; }
// 可写访问一个'node'
node& at (int x) { … }
// 只读访问一个'node'
node const& at (int x) const { … }
};
成员声明 vs. 定义
class MyType {
int n_;
// 更多的成员 …
public:
// 声明 + 内联定义
int count () const { return n_; }
// 只声明
double foo (int, int);
};
// 独立定义
double MyType::foo (int x, int y) {
// lots of stuff …
}
- 通常复杂的成员函数的定义会放在类外面(放到单独的源文件中)。
- 然而,像接口适配器函数、获取器(如 count)这样的小成员函数应该嵌入实现,即直接在类体中,以达到最佳性能。
- 暂时我们会将所有成员函数保持内联,直到我们了解有关分离编译的知识。
操作符成员函数
特殊成员函数
class X { …
Y operator [] (int i) { … }
};
使用下标运算符。
X x;
Y y = x[0];
3.初始化
成员初始化
1.成员初始化器 (C++11)
class counter {
// counter 应该从0开始
int count_ = 0;
public:
…
};
class Foo {
int i_ = 10;
double x_ = 3.14;
public:
…
};
2.构造函数初始化列表
构造函数(ctor) = 创建对象时执行的特殊成员函数
class counter {
int count_;
public:
counter(): count_{0} { }
…
};
class Foo {
int i_; // 1st
double x_; // 2nd
public:
Foo(): i_{10}, x_{3.14} { }
// same order: i_ , x_
…
};
提示:确保初始化列表中的成员顺序始终是与成员声明顺序相同!
构造函数
构造函数(ctor) = 创建对象时执行的特殊成员函数
- 构造函数的 函数名称 = 类名称
- 没有返回类型
- 可以通过初始化列表初始化数据成员
- 可以在第一次使用对象之前执行代码
- 可用于建立不变量
- 默认构造函数 = 不带参数的构造函数
构造函数的独立定义
与其他成员函数的方式相同
class MyType { …
public:
MyType (); // 声明
…
};
// 独立定义
MyType::MyType (): … { … }
注意:确保初始化列表中的成员顺序始终是 与成员声明顺序相同!
- 初始化列表中的不同顺序可能会导致未定义的行为,例如访问未初始化的内存。
- 这里,在默认构造函数中,我们需要确保只有在min_和max_被初始化之后才能访问v_{min_,max_}。
- 有些编译器会对此发出警告:例如 g++/clang++ 使用 -Wall 或 -Wreorder 选项,这就是为什么要始终启用并且决不忽略编译器警告的另一个原因!
默认构造函数与自定义构造函数
没有用户定义的构造函数⇒编译器生成一个
class BoringType { public: int i = 0; };
BoringType obj1; // 正确
BoringType obj2 {}; // 正确
至少有一个特殊构造函数
⇒ 编译器不生成默认构造函数
class SomeType { …
public:
// special constructor:
explicit SomeType (int x) … { … }
};
SomeType s1 {1}; // 特殊 (int) 构造函数
SomeType s2; // 编译错误: 没有默认构造函数!
SomeType s3 {}; // 编译错误: 没有默认构造函数!
TypeName() = default;
⇒ 编译器生成默认构造函数的实现(编译器实现没有参数的构造函数就是默认构造函数)
显式构造函数 ↔ 隐式转换
// 函数有一个 'Counter' 参数
void foo (Counter c) { … }
void bar (Counter const& c) { … }
隐式转换(不好的方式)
class Counter {
int count_ = 0;
public:
Counter (int initial):
count_{initial} {}
…
};
// 从‘2‘创建了'Counter'对象
foo(2); // 正确
bar(2); // 正确
foo(Counter{2}); // 正确
bar(Counter{2}); // 正确
显式构造函数(推荐的方式)
class Counter {
int count_ = 0;
public:
explicit
Counter (int initial):
count_{initial} {}
…
};
// 没有隐式转换:
foo(2); // 编译错误
bar(2); // 编译错误
foo(Counter{2}); // 正确
bar(Counter{2}); // 正确
注意:默认情况下,让用户定义的构造函数显式!
- 隐式转换是难以发现的错误的主要来源!
- 只有在绝对必要且含义明确时,才使用非显式构造函数,如果需要直接从参数类型进行转换。
- 一些较老的教材和使用 C++98 的人可能会告诉你,只需要关心单参数构造函数的隐式转换。然而自C++11以来,情况已经改变,因为现在你也可以从花括号括起的值列表中隐式地构造对象。
构造函数委托
= 调用初始化列表中的其他构造函数
class Range {
int a_;
int b_;
public:
// 1) 特殊构造函数
explicit Range (int a, int b): a_{a}, b_{b} {
if (b_ > a_) std::swap(a_,b_);
}
// 2) 特殊[a,a]构造 - 委托给[a,b]构造函数
explicit Range (int a): Range{a,a} {}
// 3) default constructor - delegates to [a,a] ctor
Range (): Range{0} {}
…
};
Range r1; // 3) ⇒ r1.a_: 0 r1.b_: 0
Range r2 {3}; // 2) ⇒ r2.a_: 3 r2.b_: 3
Range r3 {4,9}; // 1) ⇒ r3.a_: 4 r3.b_: 9
Range r4 {8,2}; // 1) ⇒ r4.a_: 2 r4.b_: 8
最令人困扰的解析
由于C++语法中的歧义,无法使用空括号进行对象构造:
class A { … };
A a (); // 声明了没有参数和返回值的函数'a'
A a; // 构造一个A类型对象
A a {}; // 构造一个A类型对象
4.设计,约定和风格
每种类型都应该有一个目的
- 因为这样可以减少将来对它的修改可能性。
- 降低出现新错误的风险
- 根据您的类型保持代码更加稳定
保持数据成员私有并使用成员函数访问/修改数据
- 这样用户只能通过稳定的接口与您的类型进行交互。
- 避免数据损坏 / 允许不变量保证。
- 如果你改变了类型的内部实现,类型的用户不需要改变他们的代码。
const - 限定所有非修改成员函数
- 为了清楚地表明对象的内部状态如何以及何时发生改变。
- 使您更难错误地使用您的类型。
- 启用编译器可变性检查。
- 更好地推理正确性,特别是在涉及同时访问对象的情况下,例如来自多个线程。
接口应该易于正确使用,并且难以错误使用。
— Scott Meyers
函数或类型的用户不应该对其目的、参数的意义、先决条件/后置条件和副作用感到困惑。
接口中的数据类型
#include <cstdint>
#include <numeric_limits>
class monotonous_counter {
public:
// 公共类型别名
using value_type = std::uint64_t;
private:
value_type count_ = 0;
public:
value_type reading () const { return count_; }
…
};
const auto max = std::numeric_limits<monotonous_counter::value_type>::max();
不要泄露实现细节:
- 只有当别名类型在您的类的公共接口中使用时,即作为公共成员函数的返回类型或参数时,才将类型别名公开。
- 如果别名类型只在私有成员函数中使用或用于私有数据成员,请不要将类型别名公开。
成员和非成员
如何实现一个特性/添加新功能?
- 只需要访问公共数据(例如通过成员函数访问)⇒ 实现为独立函数
- 需要访问私有数据⇒作为成员函数实现
示例:间隔类型 gap类
如何实现一个函数,使新的间隔对象的两个边界都移动相同的量?
class gap {
int a_;
int b_;
public:
explicit gap (int a, int b): a_{a}, b_{b} {}
int a () const { return a_; }
int b () const { return b_; }
};
推荐的独立式函数实现
gap shifted (gap const& g, int x) {
return gap{g.a()+x, g.b()+x};
}
- 实现仅依赖于gap的公共接口
- 我们没有更改类型 gap 本身 ⇒ 依赖它的其他代码不需要重新编译
不推荐的成员函数实现
class gap {
…
gap shifted (int x) const {
return gap{a_+x, b_+x};
}
};
- gap的其他用户可能想要一个具有不同语义的移位函数,但他们现在只能使用我们的函数了。
- 所有其他代码(取决于 gap)都需要重新编译。
避免使用Setter/Getter对!
- 使用动作/动词函数而不是仅仅使用设置器(Setter)。
- 通常可以更好地对问题进行建模。
- 更精细的控制。
- 更好的代码可读性/意图表达。
推荐的描述性操作:
class Account { …
void deposit (Money const&);
Money try_withdraw (Money const&);
Money const& balance () const;
};
不推荐的Setter/Getter对:
class Account { …
void set_balance (Money const&);
Money const& balance () const;
};
命名
名称应反映类型/函数的用途
推荐的:可理解的
class IPv6_Address {…};
class ThreadPool {…};
class cuboid {…};
double volume (cuboid const&) {…}
不推荐的:太笼统了
class Manager {…};
class Starter {…};
class Pool {…};
int get_number (Pool const&) {…}
不要在类型、变量、函数、私有数据成员等名称中使用前导下划线或双下划线!
- 以下划线开头和/或包含双下划线的名称是保留给标准库和/或编译器生成的实体的。
- 使用具有前置下划线或双下划线的名称可能会引发未定义行为!
- 一个常见且没有问题的约定是在私有数据成员后面加下划线。
使用专用类型!
- 限制输入参数值
- 确保中间结果的有效性
- 保证返回值有效性
⇒编译器作为正确性检查器,如果它能编译通过,它应该是正确的
// 明确的接口:
double volume (Cuboid const&);
// 输入保证:角度以弧度为单位
Square make_rotated (Square const&, Radians angle);
// 只接受有效数量(例如:> 0)
Gadget duplicate (Gadget const& original, Quantity times);
// 结果保证:向量已被规范化。
UnitVector3d dominant_direction (WindField const&);
//避免混淆,使用一个好的单位库。
si::kg mass (EllipsoidShell const&, si::g_cm3 density);
bool has_cycles (DirectedGraph const&);
// 易于理解的控制流程和逻辑:
Taxon species1 = classify(image1);
Taxon species2 = classify(image2);
Taxon lca = taxonomy.lowest_common_ancestor(species1, species2);
5.示例实现
示例1:单向的计数器
- 新计数器从 0 开始
- 只能往上数,不能往下数。
- 对当前计数值的只读访问
#include <iostream> // std::cout
#include <cstdint> // std::uint64_t
class monotonous_counter {
public:
using value_type = std::uint64_t;
private:
value_type count_ = 0; // initial
public:
monotonous_counter () = default;
explicit monotonous_counter (value_type init) noexcept: count_{init} {}
void increment () noexcept { ++count_; }
[[nodiscard]] value_type reading () const noexcept { return count_; }
};
int main () {
monotonous_counter c;
c.increment();
std::cout << c.reading(); // prints 1
c.increment();
c.increment();
std::cout << c.reading(); // prints 3
}
运行示例
示例2:递增序列
- 存储整数
- 通过索引对存储元素进行只读访问
- 只能插入新元素,但不能删除它们
- 元素始终按升序排序
- 只能通过公共接口修改内容
‘insert’ 操作的实现以及 ‘begin’ 和 ‘end’ 成员函数的作用在我们学习了迭代器和标准库中的算法后会变得更加清晰。
#include <iostream> // std::cout
#include <vector> // std::vector
#include <algorithm> // std::lower_bound
class ascending_sequence {
public:
using value_type = int;
private:
using storage_t = std::vector<value_type>;
storage_t seq_;
public:
using size_type = storage_t::size_type;
void insert (value_type x) {
// use binary search to find insert position
seq_.insert(std::lower_bound(seq_.begin(), seq_.end(), x), x);
}
[[nodiscard]] value_type operator [] (size_type idx) const noexcept {
return seq_[idx]; }
[[nodiscard]] size_type size () const noexcept { return seq_.size(); }
// enable range based iteration
[[nodiscard]] auto begin () const noexcept { return seq_.begin(); }
[[nodiscard]] auto end () const noexcept { return seq_.end(); }
};
int main () {
ascending_sequence s; // s.seq_:
s.insert(7); // s.seq_: 7
s.insert(2); // s.seq_: 27
s.insert(4); // s.seq_: 247
s.insert(9); // s.seq_: 2479
s.insert(5); // s.seq_: 24579
std::cout << s[3]; // prints 7
for (auto x : s) {
std::cout << x <<' '; // 2 4 5 7 9
}
// use type aliases
ascending_sequence::value_type x = 1;
ascending_sequence::size_type n = 2;
}
运行示例
附上原文地址
如果文章对您有用,请随手点个赞,谢谢!^_^