简介
为什么每一名程序员都应该学习 C++?学习多种语言会使你在软件开发生涯中更加自信和娴熟。例如,如果你掌握了 Go 语言,你就会了解几个令人印象深刻的语言设计概念,提高自己的通用编程技能。C++ 是一种高级语言,它提供了比 C 语言更高级、对人更友好的抽象。但是,C++ 鼓励人们像 C 语言一样使用指针和手动管理内存。另外,C++ 标准库的设计注重计算机科学概念、性能和灵活性,而不是仅仅注重开发的便利性。因此,当你学习 C++ 时,会无意中学习到计算机科学的基础知识。每位开发人员都应该学习的 5 种编程语言。
像 Golang 这样符合现代潮流的编程语言,通过提供类似 C 语言的最小语法和自动内存管理(通过垃圾收集)与 C++ 竞争。但是,Go 会生成很大的二进制文件,所以它不适合于轻量级的场景。这就是为什么大多数程序员使用 Go 来构建高性能的云工具,因为对云环境来说,二进制文件的大小并不是问题。在高性能、轻量级的软件开发方面,程序员仍然喜欢用 C++ 而不是 Go。Go 通常是静态链接标准库实现,当我们导入 fmt 时,它会将一个 1.2MB 的 Go 最小二进制文件增加到 1.8MB。C++ 通常是动态链接,当我们包含 iostream 时,它会将一个 16.5KB 的最小二进制文件增加到 17.3KB。在 GNU/Linux 平台上,Go 二进制文件大小增加了 50%,而 C++ 二进制文件大小增加了不到 5%。
和 C 相比,C++ 向上做了更多的抽象和扩展。C++ 提供了 STL、类、模板、智能指针等一系列新特性,使得基于 C++ 进行的大型软件开发变得更加高效,而且进一步避免了使用原始 C 时容易出现的内存泄露等问题。
标准库
基本每种编程语言,包括C/C++语言,都包括两个部分:
- 语法部分:比如判断、循环、定义变量、函数、类、注释等,及一些内置类型。
- 标准库部分:该语言最常用的函数或类库,比如输入输出、内存管理、字符串操作、数学函数、线程相关等。因为太基础常用,所以被纳入语言标准的一部分,称为标准库。 狭义的语言,仅仅是指语法部分,可称之为语言本身;广义的语言,是指语法部分+标准库部分。语言的标准库+各种第三方库,组成了我们程序中常见的各种库。
- 比如内存相关:Linux系统原本提供的内存分配函数(也叫API),是brk、mmap等,而Windows系统提供的API是HeapAlloc、HeapFree、VirtualAlloc等,C语言(准确说是C运行时库)把各操作系统提供的API封装成统一的malloc、free(事实上,malloc内部并不是简单调用各系统API,而是做了一个内存池,小内存从池中分配,大内存调系统API)。
- 对于文件:C语言提供的fopen、fread、fwrite等,即FILE系列的缓冲文件,同样是调用各平台的系统API实现的。Linux下是调用open、write等非缓冲文件接口,Windows下是调用CreateFile、WriteFile等。
- 平台无关或关系不大的函数,比如字符串、数学相关函数等。
- 提供了一个统一的main入口。为了支持程序的运行,还在统一的main函数前后做了一些逻辑,比如在main之前初始化一些全局变量、环境变量、命令行参数等,在main之后做一些资源的清理等。
这些库函数是事先编译好的,通常随编译器一起发布,编译器在编译我们的程序时,自动帮我们做了链接,让我们无感。这也是为什么教科书说C语言是可移植语言的原因之一,因为这些跨平台实现帮我们屏蔽了操作系统的差异,否则就需要调各操作系统的API,为不同平台编写不同的代码了。PS:即便是为了可移植,各个语言也得提供runtime来屏蔽操作系统差异
C++语言,是在C语言的基础上提供了更多的功能特性,包括:
- 语言本身,提供了类、多态、new/delete、异常、RTTI等。
- 标准库,提供了string、vector、list、map等各种容器和算法,以及输入输出流、智能指针、日期时间、线程相关等。 C++运行时库,也是在C运行时库的基础上,为C++语言的这两部分特性提供支持。
C和C++语言,都是只有一种标准,但有多种实现。
- 一种标准。即 ISO 的C/C++标准委员会制定的,但这个标准按时间顺序,有多个版本,后一个版本在前一个版本基础上改进,增加新的特性,同时也可能废弃一些特性。对于C语言,有C89/C90、C99、C11等;C++ 有 C++98、C++11、C++17、C++20 等。
- 多种实现。语言的具体实现,是由各编译器厂商完成的,包括语法语义的实现,和标准库(运行时库)的实现。主要的有Linux下GNU的实现(其中C运行时库是GNU C Library,简称glibc),Windows下的MSVC实现、以及LLVM的实现等。 标准和实现不一定是完全一致的。比如某编译器版本,可能对标准某特性的遵循不够完善,也有可能某个编译器先实现了某语言特性,然后才被纳入标准。
多线程
在 C++ 语言里,线程就是一个能够独立运行的函数。
auto f = []() // 定义一个lambda表达式
{
cout << "tid=" <<
this_thread::get_id() << endl;
};
thread t(f); // 启动一个线程,运行函数f
多线程说它不难,是因为线程本身的概念是很简单的,只要规划好要做的工作,不与外部有过多的竞争读写,很容易就能避开“坑”,充分利用多线程,“跑满”CPU。说它难,则是因为现实的业务往往非常复杂,很难做到完美的解耦。一旦线程之间有共享数据的需求,麻烦就接踵而至,因为要考虑各种情况、用各种手段去同步数据。
thread_local
thread_local int n = 0; // 线程局部存储变量
auto f = [&](int x) // 在线程里运行的lambda表达式,捕获引用
{
n += x; // 使用线程局部变量,互不影响
cout << n; // 输出,验证结果
};
thread t1(f, 10); // 启动两个线程,运行函数f
thread t2(f, 20);
原子变量
using atomic_bool = std::atomic<bool>; // 原子化的bool
using atomic_int = std::atomic<int>; // 原子化的int
using atomic_long = std::atomic<long>; // 原子化的long
atomic_int x {0}; // 初始化,不能用=
atomic_long y {1000L}; // 初始化,只能用圆括号或者花括号
assert(++x == 1); // 自增运算
y += 200; // 加法运算
assert(y < 2000); // 比较运算
异步
auto task = [](auto x) // 在线程里运行的lambda表达式
{
this_thread::sleep_for( x * 1ms); // 线程睡眠
cout << "sleep for " << x << endl;
return x;
};
auto f = std::async(task, 10); // 启动一个异步任务
f.wait(); // 等待任务完成
assert(f.valid()); // 确实已经完成了任务
cout << f.get() << endl; // 获取任务的执行结果
async() 会返回一个 future 变量,可以认为是代表了执行结果的“期货”,如果任务有返回值,就可以用成员函数 get() 获取。不过要特别注意,get() 只能调一次,再次获取结果会发生错误,抛出异常 std::future_error。另外,这里还有一个很隐蔽的“坑”,如果你不显式获取 async() 的返回值(即 future 对象),它就会同步阻塞直至任务完成(由于临时对象的析构函数),于是“async”就变成了“sync”。所以,即使我们不关心返回值,也总要用 auto 来配合 async(),避免同步阻塞,就像下面的示例代码那样:
std::async(task, ...); // 没有显式获取future,被同步阻塞
auto f = std::async(task, ...); // 只有上一个任务完成后才能被执行
内存管理
RAII 是一种管理资源的惯用方法,在对象的构造函数中获取资源,在析构函数中释放资源。
当我们在一个函数内部使用局部变量,当退出了这个局部变量的作用域时,这个变量也就别销毁了;当这个变量是类对象时,这个时候,就会自动调用这个类的析构函数。由于系统的资源不具有自动释放的功能,而C++中的类具有自动调用析构函数的功能。如果把资源用类进行封装起来,对资源操作都封装在类的内部,在析构函数中进行释放资源。当定义的局部变量的生命结束时,它的析构函数就会自动的被调用,如此,就不用程序员显示的去调用释放资源的操作了。
C++从遗忘到入门 未读
面向对象
class Person {
public:
void doWork(); // 方法,类对外提供的一系列操作实例的函数
private:
std::string name; // 成员变量,封装到类中的属性,保存内部状态信息
int age;
};
C++ 里类的四大函数你一定知道吧,它们是构造函数、析构函数、拷贝构造函数、拷贝赋值函数。C++11 因为引入了右值(Rvalue)和转移(Move),又多出了两大函数:转移构造函数和转移赋值函数。所以,在现代 C++ 里,一个类总是会有六大基本函数:三个构造、两个赋值、一个析构。好在 C++ 编译器会自动为我们生成这些函数的默认实现,省去我们重复编写的时间和精力。但我建议,对于比较重要的构造函数和析构函数,应该用“= default”的形式,明确地告诉编译器(和代码阅读者):“应该实现这个函数,但我不想自己写。”这样编译器就得到了明确的指示,可以做更好的优化。
class DemoClass final
{
public:
DemoClass() = default; // 明确告诉编译器,使用默认实现
~DemoClass() = default; // 明确告诉编译器,使用默认实现
};
class DemoClass final
{
public:
DemoClass(const DemoClass&) = delete; // 禁止拷贝构造
DemoClass& operator=(const DemoClass&) = delete; // 禁止拷贝赋值
};
// 因为 C++ 有隐式构造和隐式转型的规则,如果你的类里有单参数的构造函数,或者是转型操作符函数,为了防止意外的类型转换,保证安全,就要使用“explicit”将这些函数标记为“显式”。
class DemoClass final
{
public:
explicit DemoClass(const string_type& str) // 显式单参构造函数
{ ... }
explicit operator bool() // 显式转型为bool
{ ... }
};