简介
为什么每一名程序员都应该学习 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
{ ... }
};
函数==>lambda==>函数式编程
C++ 里的函数概念来源于 C,是面向过程编程范式的基本部件。但严格来说,它其实应该叫“子过程”(sub-procedure)、“子例程”(sub-routine),是命令的集合、操作步骤的抽象。函数的目的是封装执行的细节,简化程序的复杂度,但因为它有入口参数,有返回值,形式上和数学里的函数很像,所以就被称为“函数”。在语法层面上,C/C++ 里的函数是比较特别的。虽然有函数类型,但不存在对应类型的变量,不能直接操作,只能用指针去间接操作(即函数指针),这让函数在类型体系里显得有点“格格不入”。
函数在用法上也有一些特殊之处。在 C/C++ 里,所有的函数都是全局的,没有生存周期的概念(static、名字空间的作用很弱,只是简单限制了应用范围,避免名字冲突)。而且函数也都是平级的,不能在函数里再定义函数,也就是不允许定义嵌套函数、函数套函数。所以,在面向过程编程范式里,函数和变量虽然是程序里最关键的两个组成部分,但却因为没有值、没有作用域而不能一致地处理。函数只能是函数,变量只能是变量,彼此之间虽不能说是“势同水火”,但至少是“泾渭分明”。
C++11 引入的 lambda 表达式,C++ 没有为 lambda 表达式引入新的关键字,并没有“lambda”这样的词汇,而是用了一个特殊的形式“[]”,术语叫“lambda 引出符”(lambda introducer)。在 lambda 引出符后面,就可以像普通函数那样,用圆括号声明入口参数,用花括号定义函数体。PS:“轻度”使用 lambda 表达式能够改善代码,比如用“标准库容器+lambda”的方式来替换难以维护的 if/else/switch,可读性要比大量的分支语句好得多。
auto func = [](int x) // 定义一个lambda表达式
{
cout << x*x << endl; // lambda表达式的具体内容
};
func(3); // 调用lambda表达式
暂时不考虑代码里面的语法细节,单从第一印象上,我们可以看到有一个函数,但更重要的,是这个函数采用了赋值的方式,存入了一个变量。这就是 lambda 表达式与普通函数最大、也是最根本的区别。因为 lambda 表达式是一个变量,所以,我们就可以“按需分配”,随时随地在调用点“就地”定义函数,限制它的作用域和生命周期,实现函数的局部化。而且,因为 lambda 表达式和变量一样是“一等公民”,用起来也就更灵活自由,能对它做各种运算,生成新的函数。这就像是数学里的复合函数那样,把多个简单功能的小 lambda 表达式组合,变成一个复杂的大 lambda 表达式。
如果你比较熟悉 C++98,或者看过一些相关的资料,可能会觉得 lambda 表达式只不过是函数对象(function object)的一种简化形式,只是一个好用的“语法糖”(syntactic sugar)。大道理上是没错的,但如果把它简单地等同于函数对象,认为它只是免去了手写函数对象的麻烦,那就实在是有点太“肤浅”了。lambda 表达式为 C++ 带来的变化可以说是革命性的。虽然它表面上只是一个很小的改进,简化了函数的声明 / 定义,但深层次带来的编程理念的变化,却是非常巨大的。这和 C++ 当初引入 bool、class、template 这些特性时有点类似,乍看上去好像只是一点点的语法改变,但后果却如同雪崩,促使人们更多地去思考、探索新的编程方向,而 lambda 引出的全新思维方式就是“函数式编程”——把写计算机程序看作是数学意义上的求解函数。
C++ 里的 lambda 表达式除了可以像普通函数那样被调用,还有一个普通函数所不具备的特殊本领,就是可以“捕获”外部变量,在内部的代码里直接操作,就是大名鼎鼎的“闭包”(closure),这让它真正超越了函数和函数对象。它虽然在出现时被定义,但因为保存了定义时捕获的外部变量,就可以跳离定义点,把这段代码“打包”传递到其他地方去执行,而仅凭函数的入口参数是无法做到这一点的。这就导致函数式编程与命令式编程(即面向过程)在结构上有很大不同,程序流程不再是按步骤执行的“死程序”,而是一个个的“活函数”,像做数学题那样逐步计算、推导出结果,有点像下面的这样:
auto a = [](int x) // a函数执行一个功能
{...}
auto b = [](double x) // b函数执行一个功能
{...}
auto c = [](string str) // c函数执行一个功能
{...}
auto f = [](...) // f函数执行一个功能
{...}
return f(a, b, c) // f调用a/b/c运算得到结果
你也可以再对比面向对象来理解。在面向对象编程里,程序是由一个个实体对象组成的,对象通信完成任务。而在函数式编程里,程序是由一个个函数组成的,函数互相嵌套、组合、调用完成任务。
其它
罗剑锋:C++ 最大的优点是与 C 兼容,最大的缺点也是与 C 兼容。一方面,它是 C 之外唯一成熟可靠的系统级编程语言(目前 Rust 还没有达到可以和 C++“叫板”的程度),大部分用 C 的地方都能用 C++ 替代,这就让它拥有了广阔的应用天地。而面向对象、泛型等编程范式,又比 C 能够更好地组织代码,提高抽象层次,管理复杂的软件项目。但另一方面,为了保持与 C 兼容,C++ 的改革和发展也被“束缚了手脚”,做出任何新设计时,都要考虑是否会对 C 代码造成潜在的破坏。这就使得很多 C++ 新特性要么是“一拖再拖”,要么是“半成品”,要么是“古里古怪”,最后导致 C++ 变得有些不伦不类,丢掉了编程语言本应该的简洁、纯粹。如果你看看新出的 C++17、C++20,这两方面就表现得特别明显(比如结构化绑定、模块、协程、模板 lambda)。我觉得,对于 C++ 这样复杂的编程语言,你要把握一个基本原则:不要当“语言律师”(language lawyer)。也就是说,不要像孔乙己那样,沉迷于“茴”字有多少种写法,又或者是“抖机灵”式的代码,而是要注重实践、实用。因为 C++ 的编程范式太多,“摊子”实在是铺得太大,它为了避免各种特性可能导致的歧义和冲突,就会有许许多多细致到“令人发指”的规定,我们在学习的时候,一不小心就会钻进细节里出不来了。这样的例子有很多,比如说 ADL、引用折叠、可变参数模板、“++”的前置和后置用法、模板声明里的 typename 和 class、初始化列表与构造函数、重载函数里的默认参数……弄懂这些位于“犄角旮旯”里的特性(无贬义),需要花费我们很多的脑力,但在我们一般的开发过程中,通常很少会触及这些点,或者说是会尽力避免,它们通常只是对编译器有意义,所以在这些“细枝末节”上下功夫就不是很值了,说白了,就是性价比太低。我个人认为,在掌握了专栏里 C++11/14 知识的基础上,如果再面对一个 C++ 新的语言特性,你不能够在五分钟(或者再略长一点)的时间里理解它的含义和作用,就说明它里面的“坑”很深。你应当采用“迂回战术”,暂时放弃,不要细究,把精力集中在现有知识的消化和理解上,练好“基本功”,等你以后真正不得不用它的时候,通过实践再来学习会更好。这也是我自己多年实践的经验,希望对你有用。