在学C++Primer做的一些笔记

大概是一个月前,实在是闲的没事,在图书馆想找点C++的书学习学习这个我一直没深入的语言。本来是奔着Essential C++去的,结果找了一圈只有英文版的而且还是旧版,想想就还是算了吧,这种技术肯定是要看新标准的更好。最后只找到了这本C++ Primer,虽然我内心对这种大部头的书比较抗拒,但是要想学好一门技术,这种权威的书籍是最好的。

看看这包浆的封面和书页,这块大砖也肯定帮到过不少人吧。


基础部分

左值引用(lvalue reference)

主要用于内置类。

严格来说“引用(reference)” 指的是 “左值引用”

1
2
int ival = 1024;
int &refVal = ival;

引用本身不是一个对象,没有实际地址,所以不能定义指向引用的指针。

一旦定义了引用,就无法令其再绑定到另外的对象,之后每次使用这个引用都是访问它最初绑定的那个对象。

指针

空指针:使用nullptr(区别NULL)。

“void*”是一个特殊的指针,可用于存放任意类型对象的地址,但是不能直接操作void*指针所指的对象,因为我们不知道这个对象是什么类型的。

指向指针的引用

指针是对象,所以存在对指针的引用:

1
2
3
4
5
6
int i = 42;
int *p;
int *&r = p; // r是一个对指针p的引用

r = &i; // r引用了一个指针,因此给r赋值&i就是令p指向i
*r = 0; // 解引用r得到i

面对一条比较复杂的指针或引用的声明语句时,从右往左阅读有助于弄清楚它的真实含义。

迭代器

所有标准库容器的迭代器都定义了 == 和 != ,但是它们大多都没有定义 < 运算符。因此,要养成使用迭代器和 != 的习惯,不要太在意用的到底是哪种容器类型。

以vector为例,任何一种可能改变vector容量的操作,比如push_back,都会使该vector对象的迭代器失效。

但凡是使用了迭代器的循环体,都不要向迭代器所属的容器添加元素。

数组

多维数组的for处理
1
2
3
4
5
6
size_t cnt = 0;
for (auto &row : ia)
for (auto &col : row) {
col = cnt;
++cnt;
}
1
2
3
for (const auto &row : ia)
for (auto col : row)
cout << col << endl;

外层循环的控制变量必须为引用,因为auto对于数组的处理是转化为指针,对指针再遍历显然不合法,就会出错。而多维数组应该是数组的数组。

1
2
3
// 无法通过编译
for (auto row : ia)
for (auto col : row)

要使用范围for语句处理多维数组,除了最内层的循环外,其他所有循环的控制变量都应该是引用类型。

类型别名

1
2
3
4
5
6
7
8
using int_array = int[4];	// 新标准下类型别名声明
typedef int int_array[4]; // 等价的typedef

for (int_array *p = ia; p != ia + 3; ++p) {
for (int *q = *p; q != *p + 4; ++q)
cout << *q << ' ';
cout << endl;
}

将类型“4个整数组成的数组”命名为 int_array,用类型名 int_array 定义外层循环的控制变量。

函数

如果函数无需改变引用形参的值,最好将其声明为常量引用。

1
2
3
4
bool isShorter(const string &s1, const string &s2)
{
return s1.size() < s2.size();
}

如果类中不存在某个对象的类内初始值,就需要像其他成员一样显式的初始化它。

确定不改变值的成员函数建议加上const,从而让类的构建更灵活。

类内定义的函数自动inline,也可以在类内声明inline在外面定义,也可以只在外面定义时前面加上inline。建议在类外部定义时加上inline,使类更容易理解。inline成员函数应该与相应的类定义在同一个头文件中。

返回 *this 的成员函数

如果想要让成员函数能够链式调用(也就是一组动作的序列),就必须让函数返回一个对象,而且这个对象就是自己。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Screen {
public:
Screen &set(char);
Screen &set(pos, pos, char);
}; // 类最后花括号后有分号

inline Screen &Screen::set(char c) // 注意引用符号&的位置
{
contents[cursor] = c;
return *this;
}
inline Screen &Screen::set(pos r, pos col, char ch)
{
contents[r * width + col] = ch;
return *this;
}

一个const成员函数如果以引用的形式返回“*this”,那么它的返回类型将是常量引用,也就是说不能将其嵌入到一组动作序列中。
解决方法是重载一个const版本的成员函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Screen {
public:
Screen &display(std::ostream &os) { do_display(os); return *this;}
const Screen &display(std::ostream &os) {do_display(os); return *this;}
private:
void do_display(std::ostream &os) const {os << contents;}
}

Screen myScreen(5, 3);
const Screen blank(5, 3);
myScreen.set('#').display(cout); // 调用非常量版本
blank.display(cout); // 调用常量版本

// 这个do_display调用不会增加任何开销,因为是内联的,在实践中,设计良好的C++代码会包含大量类似于do_display的小函数。

友元

友元不具有传递性,每个类负责控制自己的友元类或者友元函数。

名字查找

类型名的定义通常出现在类的开始处,这样就能确保所有使用该类型的成员都出现在类名的定义之后。

构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ConstRef {
public:
ConstRef (int ii);
private:
int i;
const int ci;
int &ri;
}

// 错误写法
ConstRef::ConstRef(int ii)
{
i = ii;
ci = ii;
ri = i;
}
// 正确
ConstRef::ConstRef(int ii): i(ii), ci(ii), ri(i) { }

初始化 != 定义 + 赋值,建议使用构造函数初始值,除了效率问题,能避免意想不到的编译错误。

构造函数初始值列表不在乎顺序,实际初始化的顺序是按照类中对象的出现顺序的,为了避免出现使用未定义的值初始化,尽量避免使用某些成员初始化其他成员。

定义了其他构造函数,那么最好也提供一个默认构造函数。

静态成员static

在类的外部定义静态成员时,不能重复static关键字,该关键字只出现在类内部的声明语句。

一个常量静态数据成员在类内部被初始化了,通常情况下也应该在类的外部定义一下该成员。如果类内提供了初始值,则定义不能再指定一个初始值了。

IO类

IO对象无拷贝或赋值,不能将形参或返回类型设置为流类型,通常采用引用类型。并且读写会改变其状态,所以也不能const。

顺序容器

向一个vector、string或deque插入元素会使所有指向容器的迭代器、引用和指针失效。

push只是将元素进行拷贝,先创建一个局部的再压入容器。emplace在原来的容器内存空间中直接构造一个新元素。

如果要定义一个变量来保存一个返回的引用,那么这个变量也必须是引用类型的。

Lambda表达式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 一个例子
void biggies(vector<string> &words, vector<string>::size_type sz)
{
elimDups(words); // 将words按字典序排序,删除重复单词。
stable_sort(words.begin(), words.end(), [](const string &a, const string &b){ return a.size() < b.size(); }); // 按长度排序,长度相同的维持字典序。
auto wc = find_if(words.begin(), words.end(), [sz](const string &a){ return a.size() >= sz; });
// 计算满足size >= sz的元素的数目。
auto count = words.end() - wc;
cout << cout << " " << make_plural(count, "word", "s") << "of length " << sz << "or longer" << endl;
// 打印长度大于等于给定值的单词,每个单词后面接一个空格。
for_each(wc, words.end(), [](const string &s){ cout << s << " "; });
cout << endl;
}

[ ]为捕获列表,只用于局部非static变量,lambda可以直接使用局部static变量和在它所在的函数之外的名字。

与函数不能返回一个局部变量的引用类似,返回的lambda也不能包含引用捕获,因为在函数结束后局部变量就已经被销毁了。以引用方式捕获一个变量时,必须保证在lambda执行时变量时存在的。

一般来说应该尽量减少捕获的数据量来避免潜在的捕获导致的问题,如果可能,应避免捕获指针或引用。

值捕获如果要修改要加上mutable

1
2
3
4
5
6
7
void fcn3()
{
size_t v1 = 42;
auto f = [v1] () mutable { return ++v1; };
v1 = 0;
auto j = f(); // j为43;
}

函数体不止一条return默认返回void,如果要指定返回类型,需要尾置。

1
2
3
4
transform(vi.begin(), vi.end(), vi.begin(), [](int i) -> int {if (i < 0) return -i; else return i; });

// 等价于
transform(vi.begin(), vi.end(), vi.begin(), [](int i) { return i < 0 ? -i : i; });

map

下标和at操作只适用于非const的map和unordered_map。

如果一个multimap或multiset中有多个元素具有给定关键字,则这些元素在容器中会相邻存储。

动态数组

int *p = new int[42]

动态数组不是数组类型,而是一个数组元素类型的指针,因此不能对动态数组调用begin()或end()。

与unique_ptr不同,shared_ptr不直接支持管理动态数组。如果希望使用shared_ptr管理一个动态数组,必须提供自己定义的删除器:

1
2
3
shared_ptr<int> sp(new int[10], [](int *p){ delete[] p; });
sp.reset();
// shared_ptr默认使用的是delete而不是delete []

shared_ptr没有下标运算,而且智能指针也不能算术运算。为了访问一个数组中的元素,要先get获取一个内置指针。

1
2
for (size_t i = 0; i != 10; ++i)
*(sp.get() + i) = i;

拷贝控制

赋值运算符通常应该返回一个指向其左侧运算对象的引用。Foo& operator=(const Foo&);

当指向一个对象的引用或指针离开作用域时,析构函数不会执行。

析构函数体自身并不直接销毁成员,成员是在析构函数体之后隐式的析构阶段中被销毁的。在整个对象销毁过程中,析构函数体是作为成员销毁步骤之外的一部分而进行的。

如果一个类需要自定义析构函数,几乎可以肯定他也需要自定义拷贝赋值预算符和拷贝构造函数。

如果一个类需要一个拷贝构造函数,几乎可以肯定他也需要一个拷贝赋值运算符,反之亦然。但都不必然意味着也需要析构函数。

不抛出异常的移动构造函数和移动赋值运算符必须标记为noexcept。

移动操作之后,移后源对象必须保持有效的、可析构的状态,但是用户不能对其值进行任何假设。

这个部分说实话没怎么看懂,以后重新在开一篇好好梳理梳理。

重载运算与类型转换

通常情况下,不应该重载逗号、取地址、逻辑与和逻辑或运算符。

运算符被定义为类的成员时,类对象的隐式this指针绑定到第一个运算对象。

赋值运算符不论形参的类型是什么,都必须定义为成员函数。符合赋值运算符通常也应该这样做,这两类运算符都应该返回左侧运算对象的引用。

下标运算符必须是成员函数。

如果一个类包含下标运算符,则通常会定义两个版本:一个返回普通引用,另一个是类的常量成员并且返回常量引用。

定义递增递减应该同时定义前置版本和后置版本,这些运算符通常应该被定义成类的成员。

箭头运算符必须是类的成员。解引用运算符通常也是类的成员,尽管并非必须如此。

如果类重载了函数调用运算符operator(),则该类的对象被称为“函数对象”。常用在标准函数中。lambda表达式是一种简便的定义函数对象类的方式。

如果我们对同一个类既提供了转换目标是算术类型的类型转换,也提供了重载的运算符,则将会遇到重载运算符与内置运算符的二义性问题。

OOP

基类通常都应该定义一个虚析构函数,即使该函数不执行任何实际操作也是如此。

关键字virtual只能出现在类内部声明语句之前而不能用于外部的函数定义。如果基类声明成虚函数,则在派生类中隐式地也是虚函数。

派生类向基类的自动类型转换只对指针或引用类型有效。基类向派生类不存在隐式类型转换。

派生类的成员将隐藏同名的基类成员,如果要访问就要加上作用域。除了覆盖继承而来的虚函数之外,派生类最好不要重用其他定义在基类中的名字。(隐藏不是重载,而且即使派生类成员和基类成员的形参列表不一致,基类成员也任然会被隐藏掉。)

(这一部分也是相当有难度,看到后面前面的知识体系已经全乱了,面向对象的这部分确实不是这一本书的一个章节就能讲明白的,这里面涉及了相当多的设计思想。真的有得学了。C++的灵活性太强了,对程序员的束缚太小,几乎能想到的操作都能实现,这也就导致C++的语法太碎片化,学习起来容易遗忘。)

模版与范型

非类型模版参数的模版实参必须是常量表达式。

模版类型应该尽量减少对实参类型的要求。

默认情况下,对于一个实例化了的类模版,其成员只有在使用时才被实例化。

在一个类模版的作用域内,我们可以直接使用模版名而不必指定模版类型。

模版类型使用class和typename都可以,但是如果有需要让编译器知道类型的必要,就要用typename。

模版也可以有默认实参,用空尖括号<>表示。

将实参传递给带模版类型的函数形参时,能够自动应用的类型转换只有const转换及数组或函数到指针的转换。

数组的大小不同算不同类型

如果形参是一个引用,则数组不会转换为指针。

当不知道返回类型时可以使用:

1
2
3
4
5
6
7
// 尾置返回允许我们在参数列表之后声明返回类型
template <typename It>
auto fcn(It beg, It end) -> decltype(*beg)
{
// 处理序列
return *beg; // 返回序列中一个元素的引用。
}

对于一个调用,如果一个非函数模版与一个函数模版提供同样好的匹配,则选择非模版的版本。

定义可变参数版本的函数时,非可变参数版本的声明必须在作用域中。否则,可变参数版本会无限递归。

异常处理

通常情况下,如果catch接受的异常与某个继承体系有关,则最好将该catch的参数定义成引用类型。

如果在改变了参数的内容后catch语句重新抛出异常,则只有当catch异常声明是引用类型时我们对参数所做的改变才会被保留并继续传播。

处理构造函数初始值异常的唯一方法是将构造函数写成函数try语句块。

命名空间

命名空间作用域后面没有分号。

不能在一个作用域中定义另一个作用域中的成员。但是命名空间可以嵌套。

::member_name表示全局命名空间中的一个成员。

未命名的命名空间只在单个文件中有效和连续。且不能和全局作用域冲突,使用不需要前缀,可以在别的命名空间中嵌套未命名命名空间。
在文件中进行static静态声明来使得变量对整个文件有效的做法已经被C++标准取消了,现在的做法是使用未命名的命名空间。

Union

在匿名union的定义所在的作用域内该union的成员都是可以直接访问的。


后记:

书大致是翻完了,但是真正掌握的东西其实并没有太多,因为看这本书的时候总是被一些¬事打乱,看的战线拉得太长了,一个月才看完,看后面的时候前面已经忘的差不多了,知识体系完全构建不起来。特别是后面的部分语法太零碎,光是想要看懂都很困难。

这本书虽然是入门,但是更像是一本工具书,很多部分的讲解相当细致,但是很难记忆,更多的是应该在真正用到了在回过头来看,这样才能掌握,光是我这样翻翻没什么收获的。

书上讲的内容确实也很基础,但C++绝不只是这些,还有多线程、网络等这本书上根本没有涉及,路漫漫其修远兮,吾将上下而求索。

作者

Jhuoer Yen

发布于

2021-12-07

更新于

2023-09-18

许可协议

评论

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×