假期回到学校呆着无聊,因为想要在大四找到一份不错的实习,所以打算重读《C++ Primer》,着重啃掉以前遗留下来的难点。

毫无疑问这将花费我很多时间,希望我能够将这篇博客梳理出来。

数组和指针

  • 在用下标访问元素时,vector使用vector::size_type作为下标的类型,而数组下标的正确类型则是size_t

  • C++提供一种特殊的指针类型void*,它可以保存任何类型对象的地址;因为void*没有涉及类型,故而不能够使用解引用操作符*来获取其对应地址的值;
    可以执行的操作有:

与另一个指针进行比较;向函数传递void*指针或从函数返回void*指针以及void*赋值

  • 指针和引用的比较:引用(reference)和指针(pointer)都可间接访问另一个值,但二者之间有以下两个区别

1、引用总是指向某一个对象,定义引用时没有初始化是错误的。

2、给引用赋值修改的是该引用所关联的对象的值,而不是像指针那样使引用与另一个对象关联。

  • 可以对同一数组不同元素的指针做减法操作,其结果是标准库类型ptrdiff_t,与size_t一样,ptrdiff_t也是一种与机器相关的类型,在cstddef头文件中定义;size_tunsigned类型,而ptrdiff_t则是signed类型

  • 当指针指向const对象,不允许用指针来改变其所指的const值;允许把非const对象的地址赋给指向const对象的指针,但之后不允许通过该指针修改对应const对象的值

  • 当指针为const指针时,该指针值不可修改,但其所指向对象的值不受限制;与任何const量一样,const指针必须在定义时初始化

  • 数组类型的变量有三个重要限制:

1、数组长度固定不变

2、编译时必须知道数组长度

3、数组只在定义它的块语句内存在

  • 每一个程序在执行时都占用一块可用的内存空间,用于存放动态分配对象,此内存空间称为程序的自由存储区。C语言使用标准库函数mallocfree分配存储空间,而C++语言使用newdelete实现相同功能

  • C++为含点操作符和解引用操作符的表达式提供了一个同义词:箭头操作符(->);例如:

*(person).name 等价于 person->name

函数

  • 利用const引用避免复制:一般情况下形参需要复制实参的值,这样会导致效率低下,一种比较好的解决方法是使用const引用;(注:当然传递后的实参是不可改变的

  • 调用函数时,可以省略有默认值的实参。调用包含默认实参的函数时,若提供实参,则覆盖默认实参值,否则使用默认实参。默认实参只需在函数声明或函数定义中指定一次即可,不可重复指定。

内联函数:

  • 在函数返回类型前加上inline即将函数指定为内联函数;内联函数适用于优化小的、只有几行的且经常被调用的函数。(注:优化效果类似于宏定义

  • 类的所有成员都必须在类定义的花括号里面声明(注:不是定义),此后就不能再为类添加任何成员。类的成员函数既可以在类的定义内也可以在类的定义外定义。

  • 编译器隐式地将在类内定义的成员函数当做内联函数。

  • 构造函数和类同名,且没有返回类型;一个类可以有多个构造函数。

现在分析构造函数形如Person(): age(0), name("yinwoods") { }

1、构造函数不含形参说明为默认构造函数。

2、冒号与花括号之间的代码称为构造函数的初始化列表,其中包含一系列成员名,每个成员名后面是括在括号中的初始值。

容器

顺序容器
  • 顺序容器有vectorlistdeque三种,其中vectordeque为元素提供随机访问,而list顾名思义,操作类似于链表。
关联容器
  • 关联容器支持通过键(key)高效地查找和读取元素,两个基本类型是mapset

  • pair类型包含两个数据值,可通过pair.firstpair.second进行访问;一般使用make_pair(first, second)来创建pair

  • 使用关联容器时,它的键不但有一个类型,而且还有一个相关的比较函数。

  • map使用下标过程中需要注意,如果该键不在map容器中,那么下标操作会插入一个具有该键的新元素。

  • 可以使用countfind检查某个键是否存在

  • 相比于mapset只是简单的键的集合

  • mapset中,一个键只能对应一个实例,而multimapmultiset允许一个键对应多个实例。

  • multiset multimap查询中,可以使用upper_bound(key)lower_bound(key)以及equal_range(key)

  • 在C++中,如果类是用struct定义的,默认成员是公有的,而类如果用class定义,默认成员为私有。
    **

  • 构造函数可以包含一个构造函数初始化列表,形如Person::Person(string name):age(21), address("...") { }(注:初始化const或引用类型数据以及没有默认构造函数成员的唯一机会是在构造函数初始化列表中)

  • 成员初始化次序就是定义成员的次序

友元(允许一个类将其非公有成员的访问权授予指定的函数或类)

声明以关键字friend开始。

通常,将友元声明成组地放在类定义的开始或结尾

隐含的this指针

在普通的非const成员函数中,this的类型是一个指向类类型的const指针,可以改变this所指向的值,但不能改变this所保存的地址。

在const成员函数中,this的类型是一个指向const类类型的const指针,既不能改变this所指向的值,也不能改变this所保存的地址。

基于上面的限制,当我们使用如下Screen类的时候会出现一个问题:

1
2
3
4
5
6
7
8
9
10
11
12
class Screen {
public:
typedef std::string::size_type index;
char get() const { return contents[cursor]; }
char get(index ht, index wd) const { index row = ht*width; return contents[row+wd]; };
Screen& set(char c) { contents[cursor] = c; return *this; }
Screen& move(index r, index c) { index row = r*width; cursor = row+c; return *this; }
private:
std::string contents;
index cursor;
index height, width;
};

如果我们想要将类操作序列连接成一个单独表达式时,比如:myScreen.move(4, 0).set('#');需要操作序列中间的函数返回对自身对象的一个引用,也即定义使用Screen &,返回值使用return *this

假如说我们又有了一个新的需求,希望能够在序列中使用display()函数,比如:myScreen.move(4, 0).set('#').display(cout),display的用法暗示应该返回一个Screen引用,并接受一个ostream的引用。

如果display被定义为一个const成员,则它的返回类型也必须是const Screen&。这就带来了一个问题,如果我们想在display后接操作序列,比如:myScreen.display(cout).set('#')就会出现问题,因为display返回一个const引用,而对const引用调用set函数是违法的。

一个比较好的解决方法是定义两个display,通过函数的const重载来解决这个问题。

1
2
3
4
5
6
7
8
class Screen {
public:
...
Screen& display(std::ostream &os) { do_display(os); return *this; }
const Screen& display(std::ostream &os) const { do_display(os); return *this; }
private:
void do_display(std::ostream &os) { os << contents; }
}

现在当我们将display嵌入一个长表达式中时会调用非const版display,当我们display一个const对象时,就会调用const版本。

构造函数

类定义在两个阶段中处理:

1、首先,编译成员声明。

2、只有在所有成员出现之后,才编译它们的定义本身。

当我们想要抑制由构造函数定义的隐式转换时可以使用explicit关键字声明(定义时不需要)。

一般我们把单形参构造函数声明为explicit,这样做是为了避免我们不希望发生的转换,当转换有用时,可以显示地构造对象。

类成员的显示初始化:

1
2
3
4
5
6
struct Data {
int ival;
char *ptr;
};
Data val2 = { 0, 0 };
//等价于val2.ival = 0; val2.ptr = 0;

显示初始化有以下三个缺点:

1、要求类的全体数据成员都是public。

2、这种初始化易于出错,特别是有很多数据成员的情况。

3、不易增加或删除一个成员。

static成员

static成员是类的组成部分,但不是任何对象的组成部分,因此static成员函数没有this指针

因为static成员不是任何对象的组成部分,所以static成员函数不能被声明为const。另一方面,将函数声明为const就意味着不会修改该函数所属对象,这与static的定义是矛盾的。

static成员函数也不能被声明为虚函数

static数据成员声明与初始化不能同时进行,一般来说static数据成员要在类定义体的外部定义。
比如:

1
2
3
4
5
6
class Test {
public:
static int num;//声明
}
int num = 0;//定义

对于上面这个规则有一个例外,对于static const int 型可以在类的定义体中进行初始化(注意:仅限于int型, char属于int,因此也合法)。

复制控制

复制构造函数:一种特殊的构造函数,具有单个形参,该形参是对该类类型的引用。当定义一个新对象并用一个同类型的对象对它进行初始化时,将显式调用复制构造函数。当将该类型的对象传递给函数或从函数返回该类型的对象时,将隐式调用复制构造函数。

复制构造函数、赋值操作和析构函数总称为复制控制。编译器自动实现这些操作,但类也可以定义自己的版本。

复制构造函数

复制构造函数就是接受单个类类型引用形参的构造函数。
举个栗子:

1
2
3
4
5
6
class Foo {
public:
Foo();
Foo();//默认构造函数
Foo(const Foo&);//复制构造函数
}

复制构造函数可用于:

  • 根据另一个同类型的对象显式或隐式初始化一个对象。

  • 复制一个对象,将它作为实参传给一个函数。

  • 从函数返回时复制一个对象。

  • 初始化顺序容器中的元素。

  • 根据元素初始化式列表初始化数组元素。

C++支持两种初始化:直接初始化和复制初始化。复制初始化使用=符号,直接初始化将初始式放在圆括号中。

对于类而言,复制初始化的形式是首先使用指定构造函数创建一个临时对象,然后用复制构造函数将临时对象复制到正在创建的对象。

与合成默认构造函数不同,即使我们定义了其他构造函数,编译器也会自动生成合成复制构造函数。合成复制构造函数用于执行逐个成员初始化,将新对象初始化为原对象的副本。

注意:如果一个类具有数组成员,合成复制构造函数将赋值数组。

复制构造函数的形参必须为引用的原因:
我的观点是如果不使用引用,那么我们在传递一个实参的过程中,就已经在使用复制构造函数了,因为要把实参的值复制到形参中!而这就会导致一个无限循环的递归。

为了防止不必要的复制,类必须显示声明其复制构造函数为private,因为不这样做的话即使我们不声明复制构造函数,编译器也会自动合成一个。

即使将复制构造函数声明为private,类的友元和成员仍可以进行复制。如果想要连友元和成员中的复制也禁止,可以声明一个private复制构造函数但不对其进行定义。

赋值操作符

与复制构造函数一样,如果类没有定义自己的赋值操作符,编译器会合成一个。赋值操作符的实现依赖于对=操作符的重载。

赋值操作符返回类型应该与内置类型赋值运算返回的类型相同,也就是说赋值操作符返回对同一类类型的引用。

析构函数

析构函数的任务就是撤销类对象时,回收资源。动态分配的对象只有在指向该对象的指针被删除时才撤销。

值得说的是:容器中的元素总是按逆序撤销(运行容器中的类类型元素的析构函数)。

一般来说编译器自动生成的析构函数能够很好地完成任务,只有一些资源需要程序员通过析构函数来回收或者程序员希望在对象生命周期结束时执行一些操作的时候才需要自己定义析构函数。

三法则:如果需要析构函数,则需要所有三个复制控制成员,也即:复制构造函数、赋值操作符、析构函数。

与复制构造函数或赋值操作符不同,编译器总是会为我们合成一个析构函数。合成析构函数按对象创建顺序的逆序撤销每个非static成员。

管理指针成员

大多数C++类采用以下三种方式之一管理指针成员:

  1. 指针成员采取常规指针型行为。这样的类具有指针的所有缺陷但无需特殊的复制控制。

  2. 类可以实现所谓的“智能指针”行为。指针所指向的对象是共享的,但类能够防止悬垂指针。

  3. 类采取值型行为,指针所指对象是唯一的,由每个类对象独立管理。

智能指针:将一个计数器与类所指向的对象相关联。只有当计数为0时,删除对象。每次创建类的新对象时,将计数置为1,当对象作为另一对象的副本时,复制构造函数复制指针并增加与之相应的使用计数的值。调用析构函数时,析构函数减少使用计数的值,如果计数减为0,则删除基础对象。

智能指针实现代码:

1
2
3
4
5
6
7
8
//智能指针类
class U_Ptr {
friend class HasPtr;
int *ip;
size_t use;
U_Ptr(int *p) : ip(p), use(1) { }
~U_Ptr() { delete ip; }
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class HasPtr {
public:
//构造函数
HasPtr(int *p, int i) : ptr(new U_Ptr(p)), val(i) { }
//复制构造函数
HasPtr(const HasPtr &orig) : ptr(orig.ptr), val(orig.val) { ++ptr->use; }
//赋值符号
HasPtr& operator=(const HasPtr& rhs) {
//先对rhs.ptr->use加1,防止自身赋值
++rhs.ptr->use;
ptr = rhs.ptr;
val = rhs.val;
if(--ptr->use == 0)
delete ptr;
return *this;
}
//析构函数
~HasPtr() { if(--ptr->use == 0) delete ptr; }
private:
U_Ptr *ptr;
int val;
};

重载操作符与转换

重载操作符支持大多数操作符,重载形式为operator后接需定义的操作符符号。重载操作符必须有一个类类型操作数。内置类型操作符不可用于重载。重载操作符并不保证操作数的求值顺序。操作符的优先级、结合性或操作数数目不能改变

一般将算术和关系操作符定义为非成员函数,而将赋值操作符定义为成员。操作符定义为非成员函数时,通常必须将它们设置为所操作类的友元,因为操作符往往要涉及到类的private数据成员操作。

不要重载赋值、取地址、逗号等对类类型操作数有默认含义的操作符。赋值【=】、下标【[]】、调用【()】和成员访问箭头【->】等操作符必须定义为成员,将这些操作符定义为非成员函数将在编译时标记为错误。

调用函数符和函数对象,可以为类类型的对象重载函数调用操作符【()】。例如我们可以实现如下代码:

1
2
3
4
5
6
7
8
9
struct absInt {
int operator()(int val) {
return val < 0 ? -val : val;
}
}
---
int i = -43;
absInt absObj;
unsigned int ui = absObj(i);
函数对象的函数适配器

函数适配器用来特化和扩展一员和二元函数对象。

函数适配器分为两类:

1、绑定器:通过将一个操作数绑定到给定值而将二元函数对象转换为一元函数对象。含bind1st和bind2nd

如:bind2nd(less_equal<int>(), 10)将二元函数扩展为一元函数。

2、求反器:将谓词函数对象的真值求反。含not1和not2

转换与类类型

转换操作符是一种特殊的类成员函数。它定义将类类型值转变为其他类型值的转换。例如:

1
2
3
4
5
6
class SmallInt {
public:
operator int() const { return val; }
private:
std::size_t val;
}

在需要时SmallInt对象可以转为int值。

你可能会好奇int到SmallInt的转换,这其实就需要我们编写相应的构造函数来完成这种转换了。

转换函数采用通用形式:operator type()用于将类对象转为type类型;其中type可以是内置类型名、类类型名或由类型别名所定义的名字。

转换函数必须是成员函数,不能指定返回类型,并且形参表必须为空;转换函数一般不应该改变被转换的对象,因此转换操作符通常定义为const成员

需要注意的是转换操作符不具备连接性,也就是说如果我们有一个新类Integral,我们可以定义转换操作符operator SmallInt() const这样我们可以在使用SmallInt的地方使用Integral,但不能在使用int的地方使用Integral;但我们可以在使用double、float的地方使用SmallInt

类型转换中的错误:

这种转换带来了方便,同时也带来了问题,考虑下面这种情况:

1、在SmallInt类中既定义了到int的转换,又定义了到double的转换,那么在使用明确的类型转换中不会有问题,但是考虑一个函数compute(long double){},那么我们在调用compute(SmallInt)的时候将如何转换呢?这会导致调用的二义性,因此编译器报错。

2、这种情况对于不同形参类型构造函数同样适用,例如定义了两个构造函数分别为:SmallInt(int)SmallInt(double)那么我们将一个long类型的值作为构造函数实参时同样会因为调用二义性而报错。

3、考虑两个类可以相互转换的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Integral;
class SmallInt {
public:
SmallInt(Integral);
};
class Integral {
public:
operator SmallInt() const;
};
void compute(SmallInt);
Integral int_val
compute(int_val);

上面的computer函数调用会出错,同样是因为二义性,因为既可以使用SmallInt的构造函数将int_val转为SmallInt类型,也可以使用int_val的SmallInt()转换函数转为SmallInt类型。这种情况下可以使用显示类型转换来解决二义性,如compute(int_val.operator SmallInt());compute(SmallInt(int_val))

一个避免二义性的好的方法是不再二:保证最多只有一种途径将一个类型转换为另一个类型。

C++ OOP特性还可参考本站其他相关博文:

C++ 【oop】封装篇

C++ 【oop】继承篇

C++ 【oop】多态篇

C++ 【oop】模板篇