0. “hello world”
向先驱致敬,我们首先学习C++版本的“hello world”程序。
需求:想控制台输出“hello world”字符串。
C++是基于对象的。从对象的观点看,要有一个对象,用于打印该字符串。所以该需求可以实现为如下执行序列:
加上C++要求的必需构造:
然后是实现greetings类,并且把它置于:
相对wikipedia上的实现,这个版本要复杂的多,但是其中体现了解决问题的一般思路:
1. 分解问题为具体的执行序列(如何把大象装进冰箱),抽象出接口
2. 使用C++的符号描述该实现
3. 实现接口
在深入探讨如何解决问题之前,我们先以另一种角度温习一下C++。
1. 数学和逻辑类型
物理世界的基础是数学和逻辑,C++当然有着完整的数学和逻辑运算支持。为了支持不同的宽度和精度要求,C++有八种整数类型以及两种浮点数类型:
- char 和 unsigned char
- short 和 unsigned short
- int 和 unsigned int
- long 和 unsigned int
- float
- double
四则运算之外(+, -,*,/),整数类型还支持求余操作(%)。
C++逻辑常量true和false以及三种逻辑操作:
- and
- or
- not
特别约定1:对于整数类型来说,除操作返回的还是整数类型,那么除相当于求商,没有舍入。
特别约定2:如果数学运算中类型不一致,那么较低宽度或者精度的类型会自动转换为较高宽度或精度的类型。
特别约定3:以上操作如果导致上溢或者下溢,程序员必需自己负责。
特别约定4:任何非零值可以自动转为逻辑真(true),任何零值可以自动转为逻辑假(false)
实践指南1:忘记这些类型的细节差异,对于整数类型,一律用long及其无符号版本,除非有明确要求
实践指南2:关于优先级,只要记住乘除高于加减,其他的一律使用括号
实践指南3:比较浮点数之前,首先明确精度要求,使用库定义的宏
实践指南4:尽管内置的数学类型对于一般的数学运算已经足够,但是由于潜在的溢出问题(出于性能的考虑,C及其大部分派生语言都对这些问题保持了沉默),如果需要真正的数学运算支持,请寻求第三方的数学库。
实践指南5:优先使用long及其无符号变体。
实践指南6:对于表示个数,长度等等度量的值,优先使用size_t类型
2. 控制和跳转
C++提供三种基本的流程控制手段:
- 简单分支(if/else/else if/?:)
- 基于快速查表算法的多路分支(switch/case)
- 循环(for/while/do)
以及用于辅助控制的指令(continue/goto/break)。
所有这些构造在日常编程中都经常用到,需要熟练掌握,清楚不同的构造特点。
特别约定1:else和if的匹配遵循就近原则,使用明确的作用域符号({})来表达你的意图
特别约定2:case语句后必须使用break语句防止直通,除非你明确想要这样。
实践指南1:优先使用for循环,然后是while,最后的do。
实践指南2:大段的switch/case往往暗示着使用
3. 对象基础
C的世界中,数据和行为和完全隔离的,我们使用struct表达结构化数据,使用函数来表达针对特定数据的行为。这样做最明显的好处就是非常简单,易于理解。其缺点也是显而易见的,主要就是所有的名字(包括数据和行为)都是全局(static关键字部分地解决了这个问题)的,不考虑名字冲突,全局可见的名字很容易导致误用。为了把相关的数据和操作联系在一起,并且暴露尽可能少的名字,C++允许在struct中加入函数,而且可以对这些函数设置访问控制。鉴于struct默认的访问控制是公用的,新的关键字class被引入。这样,C++中的对象定义成形。但是,从实现的角度,什么是对象呢?我们从对象一些特征来理解它:
- 对象必须具有内部状态。这就是我们所说的数据。对象的用户不需要了解这些状态就可以使用对象
- 对象向外提供一些接口。用户可以调用这些接口做事情
很简单,不是吗?
有一些特殊的成员函数,可以说并不是对象的基本特征,支持他们纯粹是为了使C++对象使用起来更加方便一致,这些特殊的成员包括:
- 构造函数。用来初始化对象。有时候,对象状态必须在初始化过程中指定,所以无法使用成员函数来初始化。如引用和父对象的初始化。
- 析构函数。对象声明周期结束时的清理操作。如果对象在使用过程中申请了资源,这里是最好的释放地方。
- 对象复制函数。如何正确有效地从把一个对象的状态复制到另一个对象。对于资源来说,简单的赋值是不行的,这里就是处理这种情况的地方。
- 类型转换操作(可选)。有时候需要对象的类型转换成另一种特定的类型。对于那些句柄类(封装了某种包含丰富信息的结构的弱引用),这样的操作比较常见。
C++的基本哲学之一就是用户自定义类型(就是我们这里说的对象对应的类型)的使用应该和基本类型(如整数类型)尽可能一致。所以C++允许你自己定义当一个操作符(如算术,逻辑运算符)作用与一个对象的时候,该对象应该如何操作。这其中,比较特殊的有:
operator []
operator ()
第一个数组操作运算符,当一个对象内部包含了一个其他对象的序列是时,可以使用这个操作符很快定位到特定的对象。第二个是函数调用操作符。如果一个对象支持这样的操作,这就意味着一个对象可以被调用,就和一个普通的函数样。气势这不过是一个便利编程的方法,本质上还是调用该对象的一个成员方法。
必须要注意,所有这些可以重新定义(正确的术语是重载)的操作符,并没有谁要求比较定义成什么样子。比如你完全可以定义operator[]为函数调用,把加法操作定义为减法。所有这些都是约定俗成的。
有了对象的支持,编程就可以非常关注接口而不是细节(针对接口编程是OO的一条金科玉律)。如何把大象放进冰箱的一个实现是;
我们期望某一天C++标准委员会会考虑提供大象和冰箱的标准实现。
对于专业程序员来说,这样的实现不能令人满意,我们以后会重构这段代码。这里专门提到这一点是因为我不想这段代码被作为反面教材被列入糟糕实践列表中。
特别约定1:class默认所有的成员都是私用了,除非明确指定
实践指南1:对象的要表达概念要尽量单一
实践指南2:对象的提供的操作也要作用单一,含义明确
实践指南3:保留对象的值语义。涉及资源管理的对象要非常谨慎地对待。
实践指南4:操作符重载必须符合一般的约定
4. 对象的关系
对象多了以后,关系不可避免。对象之间可以相互作用,从耦合的角度,对象关系可以分为四类。
对象使用
最简单的是使用关系,如上面大象/冰箱例子。冰箱必须知道它可以接受大象,而大象却不必一定放在冰箱里(机箱也是可能的),这是单向关系。关系也可能是多向的,假设有一种大象只喜欢特定类型的冰箱,我们在放置之前就需要把冰箱给它看看,得到确认才可以。
对象包含
对象可以是另一个对象的一部分,这是很常见的情况。一般的冰箱是不能放置大象的,所以一定要有非常大的冷藏室的冰箱才可以。这里冷藏室也可以被看做是一个特定的对象,它属于冰箱的一部分。冷藏室坏了,冰箱也就坏了。对象包含关系只能是单向的。
对象引用
把大象放在冰箱来真是一个疯狂的想法,但是为了这样的需求大量生产这样的特制冰箱就不仅仅是疯狂了。聪明的冰箱生产商发现,其实没有必要每个冰箱都配备一个高成本的大象冷藏室。动物园真正的需求是可以在炎热的时候给大象降温,于是他们生产出一种更特别的冰箱,这个冰箱和传统的冰箱外表看不出什么区别,但是其后面有一个长长的通道,连接到统一的冷藏室。每一个大象室都有一个冰箱,但是他们公用冷藏室。这里冰箱和冷藏室的关系就是引用关系。冰箱坏了,但是冷藏室可以好好继续使用。
对于基本对象来说,对象复制的开销很小,所以C++和C一样,是基于值语义的,这意味着除非有明确实现,对象在传递的时候默认拷贝的。这导致了两个问题:
* 对象很大,在函数调用的时候频繁拷贝不合算
* 有的对象不支持简单地对象拷贝语义,如资源管理类对象
为了操作这些对象,C++提供对象的引用,有两种方式:
* 基于对象指针的弱引用
* 基于对象引用的强引用
强应用要求在对象构造的时候就明确应用关系而且中途不得更改;弱引用则允许在对象构造完成之后设置引用关系(这也意味着可以不小心设置一个无效的引用)。强引用更安全,但是限制也多。我们的新冰箱很显然是弱引用的。
必须要注意,C++中基于private派生导致的对象依赖关系也属于对象引用。我们区别这些并不是通过简单的语言构造,而是他们在实际使用过程中表现出来的特性。所以:
1. 对象属性并一定是类定义中对象的成员变量。凡是表征对象一定时候状态的都应该是。一个例子是对象有很多的状态,直接定义非常不直观。我们可以使用一个字典来保存所有的状态,然后使用不同的名字来引用这个状态。
2. 对象行为并不一定是类定义中对象的成员函数。凡是修改对象状态的行为都是对是对象行为。某些对象可以接受动态方法,然后调用。
对象派生
还有一种情况,两个对象的相似程度远大于其不同程度以至于我们可以很容易从其中相似部分抽象一个独立的对象出来。动物园管理部分发现给大象解暑非常有利于大象的身心健康(事实待确认),于是决定给河马同样的待遇。但是问题是河马大部分时间生活在水中,冰箱的门必须在水下才可以,但是大象冰箱可不是防水设计的。冰箱制造商在咨询OO专家后,决定设计一种通用的动物冰箱,这些冰箱的差别都在门的设计上。这样,对于大象和河马的不同要求,只需要设计不同的冰箱门然后安装即可。这里,大象冰箱和河马冰箱都可以看做是基本动物冰箱的派生产品。按照C++的术语,如果先生产这样的动物冰箱,然后在加上不同的门,这叫做继承;而如果仅仅有一个动物冰箱的设计规范,然后根据此规格直接生产不同的冰箱,叫做虚拟继承。本质上,派生关系表征的“是一种”的关系。这里大象冰箱和河马冰箱都“是一种”动物冰箱。对象派生关系也是单向的。
对象派生导致的对象依赖关系比对象引用更加严格,要求我们对实际的问题有非常准确清楚的理解。然而大部分的情况是,我们是慢慢地彻底理解一个东西。所以在OO设计领域,使用对象引用优先于对象派生。
实践指南1:关联对象时,当多种对象关系可用时,优先使用耦合度做小的关系。
实践指南2:避免复杂的对象关系
5. 标准库
有了C++语法语义,C++还只是一具骨架。只有相应的库存在后,它才变成有血有肉,活力洋溢的生命。除了完全兼容原有的C库外,C++提供的丰富的标准库,覆盖了日常使用中最基本的东西:
1. 字符串类。C风格字符串处理函数的强化。
2. 容器类。如变长数组vector,双队列deque,基于平衡树的map和set等
3. 基本算法。如查找,排序
4. I/O类。基于流的文件和标准输入(键盘)输出(屏幕)操作
5. 其他。如用于便利容器的迭代器,内存管理等
应该尽可能熟悉这些基本库的组成以及其各自的适用范围。
实践指南1:优先使用标准库实现
实践指南2:避免批量内存申请操作,使用vector
6. 结尾
这是一个非常初略的列表,太多的细节被有意略去。初学者应该关注从宏观的视角关注语言特性,并且使用使用这些特性解决真正的问题,而不是沉迷于语言和时间的细节。考虑到时间的常量性,这些上面花费的时间越多,就没有时间关注真正重要的设计实现问题。
个人推荐的C++入门资料包括(从易到难)
Accelerated C++
C++ Primer, 4th Edition
The C++ Programming Language, 3th Special Edition
其实第二本和第三本可以看做是参考大全。理解了基本的C++构造以后,应该学习的那些真正使C++成为C++的特性,如资源管理,模板,异常处理。不过在真正写代码之前,学习一点纪律是必要的。