10-2 到底应不应该返回对象?

前几讲里我们已经约略地提到了返回对象的问题,本讲里我们进一步展开这个话题,把返回对象这个问题讲深讲透。 F.20 《C++ 核心指南》的 F.20 这一条款是这么说的 [1]: F.20: For “out” output values, prefer return values to output parameters 翻译一下: 在函数输出数值时,尽量使用返回值而非输出参数 这条可能会让一些 C++ 老手感到惊讶——在 C++11 之前的实践里,我们完全是采用相反的做法的啊! 在解释 F.20 之前,我们先来看看我们之前的做法。 调用者负责管理内存,接口负责生成 一种常见的做法是,接口的调用者负责分配一个对象所需的内存并负责其生命周期,接口负责生成或修改该对象。这种做法意味着对象可以默认构造(甚至只是一个结构),代码一般使用错误码而非异常。 示例代码如下: MyObj obj; ec = initialize(&obj); … 这种做法和 C 是兼容的,很多程序员出于惯性也沿用了 C 的这种做法。一种略为 C++ 点的做法是使用引用代替指针,这样在上面的示例中就不需要使用 & 运算符了;但这样只是语法略有区别,本质完全相同。如果对象有合理的析构函数的话,那这种做法的主要问题是啰嗦、难于组合。你需要写更多的代码行,使用更多的中间变量,也就更容易犯错误。 假如我们已有矩阵变量 A、B 和 C,要执行一个操作 ​ R=A×B+C 那在这种做法下代码大概会写成: error_code_t add(matrix* result, const matrix& lhs, const matrix& rhs); error_code_t multiply(matrix* result, const matrix& lhs, const matrix& rhs); error_code_t ec; matrix temp; ec = multiply(&temp, a, b); if (ec !...

March 13, 2024 · zlzong

12 编译期多态:泛型编程和模板入门

相信你对多态这个面向对象的特性应该是很熟悉了。我们今天来讲一个非常 C++ 的话题,编译期多态及其相关的 C++ 概念。 面向对象和多态 在面向对象的开发里,最基本的一个特性就是**“多态” [1]**——用相同的代码得到不同结果。以我们在 [第 1 讲] 提到过的 shape 类为例,它可能会定义一些通用的功能,然后在子类里进行实现或覆盖: class shape { public: … void draw(const position&) = 0; }; 上面的类定义意味着所有的子类必须实现 draw 函数,所以可以认为 shape 是定义了一个接口(按 Java 的概念)。在面向对象的设计里,接口抽象了一些基本的行为,实现类里则去具体实现这些功能。当我们有着接口类的指针或引用时,我们实际可以唤起具体的实现类里的逻辑。比如,在一个绘图程序里,我们可以在用户选择一种形状时,把形状赋给一个 shape 的(智能)指针,在用户点击绘图区域时,执行 draw 操作。根据指针指向的形状不同,实际绘制出的可能是圆,可能是三角形,也可能是其他形状。 但这种面向对象的方式,并不是唯一一种实现多态的方式。在很多动态类型语言里,有所谓的**“鸭子”类型 [2]**: 如果一只鸟走起来像鸭子、游起泳来像鸭子、叫起来也像鸭子,那么这只鸟就可以被当作鸭子。 在这样的语言里,你可以不需要继承来实现 circle、triangle 等类,然后可以直接在这个类型的变量上调用 draw 方法。如果这个类型的对象没有 draw 方法,你就会在执行到 draw() 语句的时候得到一个错误(或异常)。 鸭子类型使得开发者可以不使用继承体系来灵活地实现一些“约定”,尤其是使得混合不同来源、使用不同对象继承体系的代码成为可能。唯一的要求只是,这些不同的对象有“共通”的成员函数。这些成员函数应当有相同的名字和相同结构的参数(并不要求参数类型相同)。 听起来很抽象?我们来看一下 C++ 中的具体例子。 容器类的共性 容器类是有很多共性的。其中,一个最最普遍的共性就是,容器类都有 begin 和 end 成员函数——这使得通用地遍历一个容器成为可能。容器类不必继承一个共同的 Container 基类,而我们仍然可以写出通用的遍历容器的代码,如使用基于范围的循环。 大部分容器是有 size 成员函数的,在“泛型”编程中,我们同样可以取得一个容器的大小,而不要求容器继承一个叫 SizeableContainer 的基类。 很多容器具有 push_back 成员函数,可以在尾部插入数据。同样,我们不需要一个叫 BackPushableContainer 的基类。在这个例子里,push_back 函数的参数显然是都不一样的,但明显,所有的 push_back 函数都只接收一个参数。...

March 13, 2024 · zlzong

12-1 c++中的模板

1. Template的基本语法 1.1 Template Class基本语法 1.1.1 Template Class的与成员变量定义 我们来回顾一下最基本的Template Class声明和定义形式: Template Class声明: template <typename T> class ClassA; Template Class定义: template <typename T> class ClassA { T member; }; template 是C++关键字,意味着我们接下来将定义一个模板。和函数一样,模板也有一系列参数。这些参数都被囊括在template之后的< >中。在上文的例子中, typename T便是模板参数。回顾一下与之相似的函数参数的声明形式: void foo(int a); T则可以类比为函数形参a,这里的“模板形参”T,也同函数形参一样取成任何你想要的名字;typename则类似于例子中函数参数类型int,它表示模板参数中的T将匹配一个类型。除了 typename 之外,我们再后面还要讲到,整型也可以作为模板的参数。 在定义完模板参数之后,便可以定义你所需要的类。不过在定义类的时候,除了一般类可以使用的类型外,你还可以使用在模板参数中使用的类型 T。可以说,这个 T是模板的精髓,因为你可以通过指定模板实参,将T替换成你所需要的类型。 例如我们用ClassA<int>来实例化模板类ClassA,那么ClassA<int>可以等同于以下的定义: // 注意:这并不是有效的C++语法,只是为了说明模板的作用 typedef class { int member; } ClassA<int>; 可以看出,通过模板参数替换类型,可以获得很多形式相同的新类型,有效减少了代码量。这种用法,我们称之为“泛型”(Generic Programming),它最常见的应用,即是STL中的容器模板类。 1.1.2 模板的使用 对于C++来说,类型最重要的作用之一就是用它去产生一个变量。例如我们定义了一个动态数组(列表)的模板类vector,它对于任意的元素类型都具有push_back和clear的操作,我们便可以如下定义这个类: template <typename T> class vector { public: void push_back(T const&); void clear(); private: T* elements; }; 此时我们的程序需要一个整型和一个浮点型的列表,那么便可以通过以下代码获得两个变量:...

March 13, 2024 · zlzong

13 编译期能做些什么?一个完整的计算世界

上一讲我们简单介绍了模板的基本用法及其在泛型编程中的应用。这一讲我们来看一下模板的另外一种重要用途——编译期计算,也称作“模板元编程”。 编译期计算 首先,我们给出一个已经被证明的结论:C++ 模板是图灵完全的 [1]。这句话的意思是,使用 C++ 模板,你可以在编译期间模拟一个完整的图灵机,也就是说,可以完成任何的计算任务。 当然,这只是理论上的结论。从实际的角度,我们并不想、也不可能在编译期完成所有的计算,更不用说编译期的编程是很容易让人看不懂的——因为这并不是语言设计的初衷。即便如此,我们也还是需要了解一下模板元编程的基本概念:它仍然有一些实用的场景,并且在实际的工程中你也可能会遇到这样的代码。虽然我们在开篇就说过不要炫技,但使用模板元编程写出的代码仍然是可理解的,尤其是如果你对递归不发怵的话。 好,闲话少叙,我们仍然拿代码说话: template <int n> struct factorial { static const int value = n * factorial<n - 1>::value; }; template <> struct factorial<0> { static const int value = 1; }; 上面定义了一个递归的阶乘函数。可以看出,它完全符合阶乘的递归定义: ​ 0! = 1 ​ n!=n×(n−1)! 除了顺序有特定的要求——先定义,才能特化——再加语法有点特别,代码基本上就是这个数学定义的简单映射了。 那我们怎么知道这个计算是不是在编译时做的呢?我们可以直接看编译输出。下面直接贴出对上面这样的代码加输出(printf("%d\n", factorial<10>::value);)在 x86-64 下的编译结果: .LC0: .string "%d\n" main: push rbp mov rbp, rsp mov esi, 3628800 mov edi, OFFSET FLAT:.LC0 mov eax, 0 call printf mov eax, 0 pop rbp ret 我们可以明确看到,编译结果里明明白白直接出现了常量 3628800。上面那些递归什么的,完全都没有了踪影。...

March 13, 2024 · zlzong

16 函数对象和lambda:进入函数式编程

本讲我们将介绍函数对象,尤其是匿名函数对象——lambda 表达式。今天的内容说难不难,但可能跟你的日常思维方式有较大的区别,建议你一定要试验一下文中的代码(使用 xeus-cling 的同学要注意:xeus-cling 似乎不太喜欢有 lambda 的代码😓;遇到有问题时,还是只能回到普通的编译执行方式了)。 C++98 的函数对象 函数对象(function object)[1] 自 C++98 开始就已经被标准化了。从概念上来说,函数对象是一个可以被当作函数来用的对象。它有时也会被叫做 functor,但这个术语在范畴论里有着完全不同的含义,还是不用为妙——否则玩函数式编程的人可能会朝着你大皱眉头的。 下面的代码定义了一个简单的加 n 的函数对象类(根据一般的惯例,我们使用了 struct 关键字而不是 class 关键字): struct adder { adder(int n) : n_(n) {} int operator()(int x) const { return x + n_; } private: int n_; }; 它看起来相当普通,唯一有点特别的地方就是定义了一个 operator(),这个运算符允许我们像调用函数一样使用小括号的语法。随后,我们可以定义一个实际的函数对象,如 C++11 形式的: auto add_2 = adder(2); 或 C++98 形式的: adder add_2(2); 得到的结果 add_2 就可以当作一个函数来用了。你如果写下 add_2(5) 的话,就会得到结果 7。 C++98 里也定义了少数高阶函数:你可以传递一个函数对象过去,结果得到一个新的函数对象。最典型的也许是目前已经从 C++17 标准里移除的 bind1st 和 bind2nd 了(在 <functional> 头文件中提供):...

March 13, 2024 · zlzong

17 函数式编程:一种越来越流行的编程范式

上一讲我们初步介绍了函数对象和 lambda 表达式,今天我们来讲讲它们的主要用途——函数式编程。 一个小例子 按惯例,我们还是从一个例子开始。想一下,如果给定一组文件名,要求数一下文件里的总文本行数,你会怎么做? 我们先规定一下函数的原型: int count_lines(const char** begin,const char** end); 也就是说,我们期待接受两个 C 字符串的迭代器,用来遍历所有的文件名;返回值代表文件中的总行数。 要测试行为是否正常,我们需要一个很小的 main 函数: int main(int argc, const char** argv) { int total_lines = count_lines(argv + 1, argv + argc); cout << "Total lines: "<< total_lines << endl; } 最传统的命令式编程大概会这样写代码: int count_file(const char* name) { int count = 0; ifstream ifs(name); string line; for (;;) { getline(ifs, line); if (!ifs) { break; } ++count; } return count; } int count_lines(const char** begin, const char** end) { int count = 0; for (; begin !...

March 13, 2024 · zlzong

19 thread和future:领略异步中的未来

编译期的烧脑我们先告个段落,今天我们开始讲一个全新的话题——并发(concurrency)。 为什么要使用并发编程? 在本世纪初之前,大部分开发人员不常需要关心并发编程;用到的时候,也多半只是在单处理器上执行一些后台任务而已。只有少数为昂贵的工作站或服务器进行开发的程序员,才会需要为并发性能而烦恼。原因无他,程序员们享受着摩尔定律带来的免费性能提升,而高速的 Intel 单 CPU 是性价比最高的系统架构,可到了 2003 年左右,大家骤然发现,“免费午餐”已经结束了 [1]。主频的提升停滞了:在 2001 年,Intel 已经有了主频 2.0 GHz 的 CPU,而 18 年后,我现在正在使用的电脑,主频也仍然只是 2.5 GHz,虽然从单核变成了四核。服务器、台式机、笔记本、移动设备的处理器都转向了多核,计算要求则从单线程变成了多线程甚至异构——不仅要使用 CPU,还得使用 GPU。 如果你不熟悉进程和线程的话,我们就先来简单介绍一下它们的关系。我们编译完执行的 C++ 程序,那在操作系统看来就是一个进程了。而每个进程里可以有一个或多个线程: 每个进程有自己的独立地址空间,不与其他进程分享;一个进程里可以有多个线程,彼此共享同一个地址空间。 堆内存、文件、套接字等资源都归进程管理,同一个进程里的多个线程可以共享使用。每个进程占用的内存和其他资源,会在进程退出或被杀死时返回给操作系统。 并发应用开发可以用多进程或多线程的方式。多线程由于可以共享资源,效率较高;反之,多进程(默认)不共享地址空间和资源,开发较为麻烦,在需要共享数据时效率也较低。但多进程安全性较好,在某一个进程出问题时,其他进程一般不受影响;而在多线程的情况下,一个线程执行了非法操作会导致整个进程退出。 我们讲 C++ 里的并发,主要讲的就是多线程。它对开发人员的挑战是全方位的。从纯逻辑的角度,并发的思维模式就比单线程更为困难。在其之上,我们还得加上: 编译器和处理器的重排问题 原子操作和数据竞争 互斥锁和死锁问题 无锁算法 条件变量 信号量 …… 即使对于专家,并发编程都是困难的,上面列举的也只是部分难点而已。对于并发的基本挑战,Herb Sutter 在他的 Effective Concurrency 专栏给出了一个较为全面的概述 [2]。要对 C++ 的并发编程有全面的了解,则可以阅读曼宁出版的 C++ Concurrency in Action(有中文版,但翻译口碑不好)[3]。而我们今天主要要介绍的,则是并发编程的基本概念,包括传统的多线程开发,以及高层抽象 future(姑且译为未来量)的用法。 基于 thread 的多线程开发 我们先来看一个使用 thread 线程类 [4] 的简单例子: #include <chrono> #include <iostream> #include <mutex> #include <thread> using namespace std; mutex output_lock; void func(const char* name){ this_thread::sleep_for(100ms); lock_guard<mutex> guard{output_lock}; cout << "I am thread " << name << '\n'; } int main() { thread t1{func, "A"}; thread t2{func, "B"}; t1....

March 13, 2024 · zlzong

20 内存模型和atomic:理解并发的复杂性

上一讲我们讨论了一些并发编程的基本概念,今天我们来讨论一个略有点绕的问题,C++ 里的内存模型和原子量。 C++98 的执行顺序问题 C++98 的年代里,开发者们已经了解了线程的概念,但 C++ 的标准里则完全没有提到线程。从实践上,估计大家觉得不提线程,C++ 也一样能实现多线程的应用程序吧。不过,很多聪明人都忽略了,下面的事实可能会产生不符合直觉预期的结果: 为了优化的必要,编译器是可以调整代码的执行顺序的。唯一的要求是,程序的“可观测”外部行为是一致的。 处理器也会对代码的执行顺序进行调整(所谓的 CPU 乱序执行)。在单处理器的情况下,这种乱序无法被程序观察到;但在多处理器的情况下,在另外一个处理器上运行的另一个线程就可能会察觉到这种不同顺序的后果了。 对于上面的后一点,大部分开发者并没有意识到。原因有好几个方面: 多处理器的系统在那时还不常见 主流的 x86 体系架构仍保持着较严格的内存访问顺序 只有在数据竞争(data race)激烈的情况下才能看到“意外”的后果 举一个例子,假设我们有两个全局变量: int x = 0; int y = 0; 然后我们在一个线程里执行: x = 1; y = 2; 在另一个线程里执行: if (y == 2) { x = 3; y = 4; } 想一下,你认为上面的代码运行完之后,x、y 的数值有几种可能? 你如果认为有两种可能,1、2 和 3、4 的话,那说明你是按典型程序员的思维模式看问题的——没有像编译器和处理器一样处理问题。事实上,1、4 也是一种结果的可能。有两个基本的原因可以造成这一后果: 编译器没有义务一定按代码里给出的顺序产生代码。事实上,跟据上下文调整代码的执行顺序,使其最有利于处理器的架构,是优化中很重要的一步。就单个线程而言,先执行 x = 1 还是先执行 y = 2 完全是件无关紧要的事:它们没有外部“可观察”的区别。 在多处理器架构中,各个处理器可能存在缓存不一致性问题。取决于具体的处理器类型、缓存策略和变量地址,对变量 y 的写入有可能先反映到主内存中去。之所以这个问题似乎并不常见,是因为常见的 x86 和 x86-64 处理器是在顺序执行方面做得最保守的——大部分其他处理器,如 ARM、DEC Alpha、PA-RISC、IBM Power、IBM z/ 架构和 Intel Itanium 在内存序问题上都比较“松散”。x86 使用的内存模型基本提供了顺序一致性(sequential consistency);相对的,ARM 使用的内存模型就只是松散一致性(relaxed consistency)。较为严格的描述,请查看参考资料 [1] 和里面提供的进一步资料。...

March 13, 2024 · zlzong