《Essential C++》是一本很好的学习C++基础的书,作者将他认为C++中的一些重要概念放到了书中,是对《C++ primer 3nd》的一个浓缩,去掉了一些细枝末节,将重点放在基础概念上。看这本书的目的是为了快速概览一遍C++。至于更高效的使用C++,后面还要继续看《Effective C++》和《More Effective C++》。这篇笔记算是对本书中重要概念的概览,省掉了例子和细节表述,加了少量的C++11的东西,介绍《Essential C++》大致讲了什么,有哪些概念。
编程基础
这一部分总的来说和C基本是没什么区别的,多了点新东西,会C的话可以很快过一遍。
基础数据类型
C++的基础数据类型,包括布尔值(bool,包括true、false)、字符(char)、整形(int、long、long long)、浮点型(float、double),long
等价long int
,比C多了布尔型。注意,string并不是基础数据类型,是STL里的类。需要类型转换的话,不建议使用强制转换,建议直接用static_cast<>
。
运算符
包括算术运算符(加减乘除,取余+,-,*,/,%)、关系运算符(小于,大于,小于等于,大于等于,相等,不等,<,>,<=,>=,==,!=),逻辑运算符(与或非,&&,||,!)。和C不同的是,除了可以直接赋值以外,还可以使用初始化列表。
# int list[] = {1, 2, 3, 4, 5};
int list[]{1, 2, 3, 4, 5};
# int i = 3;
int i{3};
条件控制
有条件(if…else)、循环(while)、选择(switch…case…default…)语句。不同的是,在C++11里加入了each for(for(elem:collect){expression;})
的表达方式。
for(int i=0; i<5; ++i)
std::cout << i << '\t';
for(int i : list)
std::cout << i << '\t';
复合类型
有指针、数组、枚举,都是C的部分,在C++里,建议分别用智能指针(shared_ptr,unique_ptr等,C++11特性)、STL(vector,string,list等)、枚举类型(enum class),为了更安全,而且使用起来也更方便。
// using point
int i = 3;
int *array_i = new int[i]; // 动态分配大小
// ......
delete[] array_i; // 这里很容易忘记
// using shared_ptr
std::shared_ptr<int> iptr = std::shared_ptr<int>(new int[i]);
// std::shared_ptr<int> 可以用auto代替,后面不需要手动调用delete
auto iptr = std::shared_ptr<int>(new int[i]);
其它也一样,使用智能指针和STL内置的类的话,不需要去手动释放内存,当超出变量的作用域时,会自动销毁,保证了安全性。C中的枚举可以很方便的进行变量转换,比如变成整数,而为了避免发生错误的转换,C++11后建议使用enum class
,而且像类型转换的话,也不建议隐式转换,建议使用static_cast
等。
STL
在C++中,STL分为数据结构和算法,它们之间的桥梁是迭代器,听说C++20会引入range,那就更好写了。数据结构有顺序容器:array、vector、list、forward_list、deque
,关联容器:map、set
,用起来也很方便,array和原始数组差不多,不能改变大小,但可以直接得到数组的大小,所以除了占的内存比原始数组大点,性能是差不多的。
vector可以当做是可大小的array,分配的连续内存,当然这个自动增长是要性能的,每次会分配大一点的内存,直到被占用完后,会创建一个更大的连续内存,再拷贝数据过去。list是双向链表的实现,forward_list是单链表,deque是双端队列。map、set是对二叉树的封装,所以使用起来效率很高,map用起来和Python里的字典类似,是键值对,每个用的pair,set是集合,不能有重复的值,要有重复的话需要用multiset。还有容器适配器和迭代器适配器,细节可以在C++ primer上找到,迭代器适配器用的可能多一点。
所以当查找多点的话用vector,插入删除比较多的话用list,性能会更好。需要注意的是进行插入和删除的时候,原来指向容器的指针可能会发生变化,也就是说容器在内存的位置可能会改变。
算法部分主要是:插入、排序、删除、查找,还有复制,计数之类的操作。
面向过程的编程风格
这一部分主要是关于函数的使用,也就是对过程的封装,实现输入输出。包括函数的输入参数的设置、内联(inline)函数、模板函数的编写,还有头文件相关的内容,像使用局部静态变量、重复定义等。
函数
函数必须定义四个部分:返回类型、函数名、参数、函数体。
// example
void print() {}
如上,返回类型是void
,可以是内置类型和定义的类,类型名为print
,一般用小写、有意义的名字来命名函数,命名规则可以使用匈牙利法和驼峰法,参数是圆括号里的内容,又叫形式参数,这里为空,大括号里的内容为函数体,这里为空,也就是说print函数没有输入值,不做任何事。
参数、变量
在C++中,参数有三种方式。
void print(int a, int* b, int& c) {}
传入的abc分别表示传值、传址、引用传递,传值和传址和C一样,一个是对变量(或对象)的拷贝,一个传递地址,c是对象的别名,类似的效果是*(& var)
。操作b和c都会对传入的变量产生影响,性能会比a高。建议使用引用传递,降低开销,而且比传指针方便。C++支持默认参数,可以给输入的参数默认值,但必须全部放到最右边,而且不能有默认参数的变量和无默认参数的变量交叉放置。
在函数中定义的局部变量在系统的栈中,随着作用域结束(函数返回),局部变量会被销毁,所以如果需要返回函数内定义的变量的话,在C++中可以采用new的方式,但是注意调用delete。
int * gen_array(int i) {
int *p = new int[i];
return p;
}
// use in main function
int *p = gen_array(3);
delete[] p;
当想要函数一直持有某个变量时,可以使用静态变量,书中的例子是:函数生成一个数列,接受的参数是数列的大小,如果存在多次调用,那么会多次从头开始生成数列,这样会存在很大的浪费,这样使用静态变量可以减小开销。
内联函数、重载函数、模板函数
当函数特别长的时候,可以把一部分分出来,写成单独的函数,这样虽然方便了阅读,但存在函数调用的开销,所以就出现了内联函数。
inline int max(int a, int b){return a>b ? a:b;}
void print(int a, int b){
int c = max(a,b);
}
//相当于
void print(int a, int b){
int c = a>b ? a:b;
}
在函数前使用inline
修饰,就变成了内联函数,降低了函数调用的开销。
重载函数和C一样,就是两个函数,函数名相同,输入参数(参数列表)数目或类型不同,如果仅仅只是返回值不一样会报错,编译器会自动匹配最适合的函数。
模板函数是C++特有的,会自动生成对应类型的函数,注意,这里是代码生成代码,是泛型的基础,模板函数的声明和定义必须放在一起,要不然调用会发生未定义的错误。
#include <string>
#include <sstream>
template<typename T>
std::string to_str(T tmp) {
std::ostringstream os;
std::string ss;
os << tmp;
ss = os.str();
return ss;
}
// function call
std::string str_a = to_str(3);
注意,函数模板不需要指定类型。
匿名函数是C++11里的内容,当要传一个函数的时候就需要这个,像STL中带条件的算法部分。主要形式是[](){}
,其中 [ ] 表示需要传入匿名函数的变量,匿名函数本身是不能访问外部的变量的,之后的()填写的是lambda函数的参数列表,和函数的参数列表一样,{}中写函数体。
#include<algorithm>
#include<vector>
std::vector<int> iv{ 0,1,2,3,4,5 };
std::remove_if(iv.begin(), iv.end(),
[](int a) {return (a % 2 == 0); });
函数指针
函数指针的定义和函数定义差不多,就是在名字前多了个*
号,会将*
号和函数指针变量名包含起来,避免发生错误,也可以使用using定义一个函数变量,更容易理解。
int (*convert)(int a, int b);
// use using
using FunctionPoint = int (*)(int a, int b);
FunctionPoint convert;
convert是指向一个参数列表是两个整型变量,返回值是整形的函数的指针,如果不加括号,convert就变成了返回值是一个指向整型变量的指针类型的函数。
直接调用函数指针也很方便,可以直接使用。
int max(int a, int b);
convert = max;
convert(3, 4);
头文件
头文件是为了将声明和实现分开,方便减少编译时间,在头文件中,需要加入编译一次的声明。
#pragma once
// or
#ifndef FILENAME_H
#define FILENAME_H
//......
#endif
在头文件里,千万不要定义变量,建议不要使用using namespace std;
,需要什么就声明什么,using std::cout; using std::vector;
这样。如果需要全局变量,可以先在.cpp中定义,然后在头文件中使用extern来声明。
泛型编程
泛型编程就是与参数的类型无关,主要的体现就是STL的泛型算法,输入各种类型的参数都可以运行,鲁棒性更好。为了达到这种效果,有更好的普适性,默认选择STL的方式,也就是使用Iterator
迭代器作为工具去操作对象。
迭代器
迭代器是对指针的封装,对一些运算符进行改造,像++、--、*等,为什么要这样做呢?很明显对于像array、vector这样的容器,++就是访问下一个元素,--就是访问下一个元素,但像list这样不是连续存储的,直接用指针++或者--会出错,所以使用了一个类去对指针进行封装,使得像list这样的容器也能够直接通过++访问下一个元素,而这个类有个专门的名字:迭代器。
在STL中,迭代器是容器类型的嵌套类型。
vector<int> ivec = {1,2,3,4};
// iter是指向ivec的第一个元素
vector<int>::iterator iter = ivec.begin();
具体怎么实现在书的第四章,里面有详细的实现过程。
泛型算法的实现
实现分以下几个步骤。
- 实现功能
- 对实现的功能泛化
- 参数的类型用模板实现
用书上例子来说,刚开始的需求是找到所有小于11的元素,第一步,先实现,比如操作的对象是vector<int>
型的,然后开始进行改造,小于和11是可以进行泛化的,函数变成了和变量a进行比较,满足操作F的所有元素,最后将具体的操作对象用模板重新实现。
在实现中是有预设的要求的,除了使用迭代器作为操作对象的工具外,容器类要实现一些具体的方法,像==、!=、=、empty()、size()、clear(),也就是说这些操作是默认存在的。
函数对象和函数对象适配器
函数对象就是某个类A的一个对象,可以当做函数来使用。
A a;
a(3,2);
书上写的函数对象是为了减少函数调用的开销,将函数调用变成inline函数标准库定义了一组函数对象,算术运算、关系运算、逻辑运算。
函数对象适配器介绍了两个,binder和negator。binder适配器用于将参数绑定到函数对象上,达到默认参数的效果。negator是对函数对象的真伪值取反,比如原来是要返回true的,使用negator后,变成了返回false。
基于对象
类是对某类事物的抽象,是有状态的编程,基于对象主要的内容是怎么去创建一个类,从class的使用变成class的设计与实现。class的定义和C的struct差不多,只是里面包含了函数和操作符的声明(可选的,也可以不写入class中),在可访问性上做了说明,有public、protected、private,下面是一个简单的class例子,不要忘记后面的分号。
class Triangular {
public:
Triangular(Shape shape){_shape = shape;}
~Triangular(){}
void get_shape();
private:
Shape _shape;
}; // Don't forget the semicolon
构造函数和析构函数
构造函数是当创建一个对象时使用的方法,其命名和类名相同,没有返回值,不能被声明成虚函数(必须被实现)。析构函数是销毁对象调用的方法,命名和构造函数类似,命名和类名相同,需要在前面加~
,没有返回值,而且没有参数。
当没有显式声明也就是没有在class里面写构造函数和析构函数时,编译器会创建默认构造函数和析构函数,默认构造函数是没有参数的。需要注意的是:当任意定义一个以上的构造函数时,默认的构造函数不会创建,如果想要一个默认的无参数的构造函数,或者不想,需要在后面加上=default
或=delete
。
Triangular() = default; // Triangular() = delete;
在声明构造函数时,可以使用成员初始化列表,简化了赋值代码,语法就是在参数列表和函数体中间用:成员变量1(形参1),成员变量2(形参2)
这样的形式。
// Triangular(Shape shape){_shape = shape;}
Triangular(Shape shape) : _shape(shape) {}
成员函数
成员函数就是类的一些操作,可以给外界调用(public),也可以不让调用(private,protected继承类内部可以访问)。为了安全起见,一般将不需要对成员变量进行修改的成员函数设为const,防止误用,还有就是const对象只能调用const的public函数。
void get_shape() const;
const函数只能访问成员函数,不能对其进行修改,如果必须要访问的话,需要在成员变量前加上mutable
,注意实现的时候,mutable
不能加在会破坏类的状态的成员变量上,下面这个例子会破坏Triangular的状态。
mutable Shape _shape;
当需要在多个同一个类间实现共享同一个变量时,比如统计这个类实现了多少个对象,就需要定义静态成员变量,成员变量一般设为private,使用起来和non-static成员变量一样。
如果某个成员函数没有访问non-static成员变量,可以将其声明成static,外部可以直接调用,比如得到某个对象最大的上限,不用创建对象,因此定义一个static函数时需要注意不能访问non-static成员变量。
如何实现一个类
按照书里面实现Iterator Class的步骤,先将类的操作和属性抽象出来,判断是否该定义成const,是属于public还是private,然后定义运算符++
、--
、<<
等等。在后面的面向对象中,会涉及到定义基类和派生类的步骤。
剩余部分讲了友元(friend)、拷贝、赋值构造函数、类里面的函数指针。
面向对象
基于对象的编程能够将某一类实物进行抽象,将需要的的特性封装到一个class里,当某两个或多个类存在is-a
(比如有声书(AudioBook类)是一种书籍(Book类))的关系时,存在相同的成员变量和函数,这时候就需要面向对象来实现类的继承和多态特性。
抽象类和派生类
继承机制制定了父子关系。一般会在父类中定义子类共有的操作,共有函数必须定义成虚函数,好让子类实现不同的操作,同时,也是为了实现多态。
#include <iostream>
class Book {
public:
virtual void say_hi() { std::cout << "This is Book class."; }
};
class AudioBook : public Book {
virtual void say_hi() {
std::cout << "This is AudioBook class";
std::cout << typeid(*this).name(); // This line can be deleted
}
virtual void wawa() {
std::cout << "WaWa.";
std::cout << typeid(*this).name(); // This line can be deleted
}
};
当类中含有纯虚函数时,该类不能定义对象。
// Pure Virtual Function
virtual void say_hi() = 0;
定义一个基类,书中写了三个步骤:1. 找出所有子类共通的操作行为(加static、const);2. 有哪些操作行为与派生类的类型相关,需要分别实现,定义成虚函数(加virtual);3. 找出每个操作行为的访问层级(加protected、public)。基类的析构函数建议用虚函数,如果基类不用创建实例,将构造函数放到protected里。
多态
C++通过动态绑定机制实现多态。
void print(Book& b)
{
b.say_hi();
}
// ...
AudioBook t1;
print(t1); // print "This is AudioBook class"
从上面的例子可以看出,print函数会根据对象的实际类型来调用相应的函数,需要注意的是当且只当传入的是基类指针或引用时才能达到多态的效果,而且,必须是派生类重写的基类的virtual方法才能达到多态的效果,比如AudioBook中的wawa函数就不能够。
在C++中,运行时类型鉴定机制(RTTI)通过typeid可以实现,typeid(*this)
会得到一个type_info
对象,这里只是简单使用typeid得到函数的类名。
以template进行编程
书中只是简单的介绍了模板,然后主要介绍如何实现模板化的二叉树,如果要了解更多关于模板的知识,可以在网上查找模板元编程。
参数化类型
参数化类型就是将类型当做参数传给类或函数。
template<typename T>
class Vec {
T var;
};
这里var的类型可以任意选定。书中在对对象进行初始化时,特别强调了要用member initialization list
来对成员变量进行初始化,防止自定义类的赋值构造函数对函数造成影响。
非类型参数
template<typename T, int N>
class Vec {
public:
T var[N];
};
这里N就是非类型参数,可以直接传入值,不一定是类型。比如库函数里的array,在定义时需要传入数组的大小。
异常处理
异常在C++中很容易用错,很多时候会在到底是返回ERROR还是抛出异常上选择,主要是在资源管理上的处理,RAII(resource acquisition is initialization)是管理资源、避免内存泄露的方法。
异常编写起来很简单,但要防止误用和滥用。
void test_exception {
throw "Panic";
}
也可以抛出某个对象,然后使用try…catch来进行捕获。
用书中的例子来说,定义了iterator_overflow类,传入两个变量index
和max
,并定义了名为what_happened
的方法打印出错信息。
try {
test_exception();
} catch (string ss) {
std::cout << ss;
} catch (iterator_overflow &iof) {
iof.what_happened();
}
会自动匹配类型,然后执行相应的代码。