上一次整理完了《c++ primer》的第二章的内容。这次整理本书的第3章内容。
这里还是声明一下,我整理的主要是自己不知道的或者需要注意的内容,以我本人的主观意志为准,并不具备普适性。
第三章就开始慢慢的接触连续、线性存储的数据结构了。字符串、数组、vector等都是存储在内存的连续空间中,而且都是线性结构。算是c++语言中的基础数据结构了。
命名空间与using
使用方式如下
using namespace::name;
其中name表示命名空间的具体名字如标准库都在std 这个命名空间,如果要引用这个命名空间的内容就写作 using namespace::std;
另外namespace可以表示作为关键字,也可以作为具体的命名空间,如果作为具体命名空间的话,name此时应该是命名空间中的类或者函数等等成员,例如要引用cin这个函数的话,可以这样写 using std::cin
在使用时除了使用命名空间之外也可以直接带上命名空间的名称,例如要使用cout 做输出时可以这么写 std::cout << "hell world" << std::endl;
使用using 可以直接引入命名空间,减少代码编写的字符数,但是当引入多个命名空间,而命名空间中又有相同的成员时,容易引发冲突。所以在使用命名空间时有下面几条建议
- 头文件中不要包含using声明
- 尽量做到每个成员单独使用using声明
string 对象
定义和初始化string对象
初始化string对象有如下几种方式:
- string() : 初始化一个空字符串
- string(const string&): 使用一个字符串来初始化另一个字符串,新字符串是传入字符串的一个副本
- string(char*): 使用一个字符数组来初始化字符串
- string(int, char): 新字符串是由连续几个相同字符组成
需要注意的是,在定义的语句中使用赋值操作符相当于调用对应的初始化语句。而在其他位置使用赋值操作符在执行复写操作
string str = "hello world"; //此处调用拷贝构造,并没有调用赋值重载函数
string 对象的操作
string的操作主要有:
- os << s: 将s的值写入到os流中,返回os
- is >> s: 从is流中读取字符串,并赋值给s,字符串以空白分分隔,返回is
- getline(is, s): 从is中读取一行,赋值给s,返回is
- s.empty(): 判断字符串是否为空,为空则返回true,否则返回false
- s.size(): 返回字符串中字符个数, 类型为string::size_type。它是一个无符号类型的值,而且编译器需要保证它能够存放任何string对象的大小。不要使用size()的返回值与int进行混合运算
- s[n]: 返回第n个字符
- s+s1: 返回s和s1拼接后的结果
- s1=s2: 将s2的值赋值给s1,执行深拷贝
- s1 == s2: 判断两个字符串是否相等
- s1 != s2:判断两个字符串不等
- <, <=, >, >=:字符串比较
处理string 中的字符
string 本身是一个字符的容器,我们可以使用迭代的方式来访问其中的每一个字符。例如
// 字符转化为大写
string s = "hello world";
for(auto it = s.begin(); it != s.end(); it++)
{
*it = toupper(*it);
}
针对这种需要在循环中迭代访问每个元素的情况,c++针对for语句进行扩展,使其能够像Java等语言那样支持自动迭代每一个元素,这种语句一般被称之为范围for。
// 统计可打印字符
string s = "hello world";
int punctt_count = 0;
for(auto c : s){
if(ispunct(c)){
++punct_count;
}
}
上述代码中c 只是s中每一个字符的拷贝,如果想像之前那样修改字符串中的字符,可以在迭代时使用引用类型
//字符串转化为大写
s = "hello world";
for(auto& c : s){
c = toupper(c);
}
所有同时具有连续存储和线性存储两个特点的数据结构都可以使用下标访问其中的元素。字符串中字符是采用线性和连续存储的。所以这里它也可以采用下标运算符
// 字符串转化为大写
string s = "hello world";
for(auto index = 0; index < s.size(); ++index)
{
s[index] = toupper(s[index]);
}
在使用下标时需要注意下标是否超过容器中存储的元素个数。由于在编译与链接时不会检查这个,如果超出在运行时将会产生未定义结果。
标准库 vector
标准库vector 表示对象的集合,里面需要存储相同类型的对象。可以看作是一个动态数组。
vector 被定义在头文件 vector
中
由于vector中存储的是对象,而引用不是对象,所以不存在存储引用的vector
定义和初始化
除了可以使用与string相同的初始化方法外,新的标准还支持使用初始化列表来初始化vector
vector<string> vec = {"Hello", "World", "Boy", "Next", "Door"};
一般来说都是预先定义一个空的vector对象,在需要的时候使用push_back或者push_front添加元素。需要注意的是在使用迭代器的过程中,不要针对容器做删减操作
同样的vector可以使用下标来访问元素,但是需要注意下标只能访问已有元素不能使用下标来添加元素,同时使用下标时需要注意范围。访问超过范围的元素,会引起越界的问题
迭代器
迭代器是一组抽象,是用来统一容器中元素访问方式的抽象。它能保证不管什么类型的容器,只要使用迭代器,就能使用相同的方式方法从头到尾访问到容器中的所有元素。在这里不用过于纠结跌打器究竟是如何实现的,只需要知道如何使用它。
另外提一句,我当初在初学的时候一直把c语言的思路带入到c++中,导致我一直认为跌迭代器就是指针或者下标,我试图使用指针和下标的方式来理解,然后发现很多地方搞的很乱,也很模糊。这个概念我是一直等待学习python和Java这种没有指针、完全面向对象的语言之后,才纠正过来。这里我想起《黑客与画家》书中提到的,编程语言的高度会影响我们看待问题高度。从我的经历来看,我慢慢的理解了这句话的意思。所以这也是我当初学习lisp的一个原因。我想看看被作者称之为数学语言,抽象程度目前最高的语言是什么样的,对我以后看问题有什么影响
迭代器提供了两种重要的抽象:提供统一的接口来遍历容器中所有元素;另外迭代器提供统一接口,让我们实际操作容器中的元素
使用迭代器
迭代器的使用如下:
- 迭代器都是使用begin 获取容器中的第一个元素;使用end获取尾元素的下一个元素
- 迭代器自身可以像操作对象的指针一样操作容器中的对象
- 迭代器比较时,比较的是两个迭代器指向的是否是同一个元素,不支持 >、<比较
- ++ 来使迭代器指向容器中下一个位置的对象,–来指向上一个位置的对象
如果不想通过迭代器改变容器中元素的值,可以使用const类型的迭代器,即 const_iterator
类型的迭代器
#+BEGIN_SRC c++
string s = "Hello World";
for(string::const_iterator it = s.begin(); it != s.end(); it++)
{
cout << *it << endl;
}
#+END_SRC
begin 和end返回的是普通类型的迭代器,c++ 11中提供了一套新的方法来获取const类型的迭代器,cbegin
和 cend
迭代器的常见运算
迭代器常见运算:
- iter + n: 迭代器向前可以加上一个整数,类似于指针加上一个整数,表示迭代器向前移动了若干个元素
- iter - n: 迭代器往前移动了若干个元素,类似于指针减去一个整数
- iter1 - iter2: 表示两个迭代器之间的间距,类似于指针的减法
-
、<、>=、<=:根据迭代器的位置来判断迭代器的大小,类似于指针的大小比较
迭代器与整数运算,如果超过了原先容器中元素的个数,那么最多只会返回容器中最后一个元素的下一个跌打器,也就是返回值为 end函数的返回
迭代器相减得到迭代器之间的距离,这个距离指的是右侧的迭代器移动多少个元素后到达左侧迭代器的位置,其类型定义为difference_type
使用迭代器来访问元素时,与使用指针访问指向的对象的方式一样,它重载了解引用运算符和箭头运算符。使我们能够像使用指针那样使用迭代器
数组
数组与vector相似
- 二者都是线性存储
- 二者存储的都是相同类型的元素
与vector不同的是:
- 数组大小固定
- 由于大小在初始化就已经确定,所以在性能上优于vector,灵活性上有些不足
定义和初始化内置数组
在初始化数组的时候需要注意:
- 数组大小的值可以是字面值常量、常量表达式、或者普通常量
- 定义数组时必须指明类型,不允许用auto由初始化值来进行推断
const unsigned int cnt = 42; //常量
constexpr unsigned int sz = 42; //常量表达式
int arr[10]; //使用字面常量指定大小
int arr2[cnt]; //使用常量初始化
int arr3[sz]; //使用常量表达式初始化
可以在初始化时不指定大小,后续会根据初始化列表中的元素个数自动推导出数组大小
同时指定了数组大小和初始化列表,如果指定大小大于初始化列表中的元素个数,那么前面几个元素按照初始化列表中的值进行初始化,后面多余的元素则初始化为默认值
如果指定大小小于初始化列表中元素个数,则直接报错
const unsigned int sz = 3;
int arr1[sz] = {1, 2, 3};
int arr2[sz] = {1}; // 等价与 arr2[sz] = {1, 0, 0}
int arr3[] = {1, 2, 3};
int arr4[sz] = {1, 2, 3, 4}; //错误,初始化列表中元素个数不能大于数组中定义的元素个数
字符数组可以直接使用字符串常量进行赋值,数组大小等于字符串长度加一
我们可以对数组中某个元素进行赋值,但是数组之间不允许直接进行拷贝和赋值
和vector中一样,数组中存储的也是对象,所以不存在存储引用的数组。
在理解关于数组的复杂声明时,采用的也是从右往左看理解的方式。或者说我们先找到与[] 结合的部分来理解,与[]结合的部分去掉之后就是数组中元素的类型。
int * ptrs[10];
int & refs[10];
int (*Parry)[10];
int (&arrRef)[10];
上面的例子中:
ptrs,首先与[]结合最紧密的是ptrs 去掉这两个部分,剩下的就是int* 这部分表示数组中元素类型是int* , 也就是这里定义了一个包含10个int指针元素的数组
refs, 首先与[]结合最紧密的是ref2,去掉这个部分,剩下的就是int&,这部分表示数组中元素类型是int&,也就是这里定义了一个包含10个指向int数据的引用的数组,由于不存在存储引用的数组,所以这里是错误的
Parry,由于有了括号,与[]结合最紧密的就变成了 int,也就是我们先定义了一个包含10个int类型的数组,而Parry本身是一个指针,所以这里定义的其实是一个指向存储了10个int类型数据的数组的指针
同样的方式分析,得到arrRef 其实是一个指向存储了10个int类型数据的数组的引用
指针和数组
在上面的例子中,已经见过了指针和数组的一些定义方式,例如ptrs 是一个存储了指针的数组,这种数组一般称之为指针数组;Parry是一个指向数组的指针,这种指针被称之为数组指针
在某些时候使用数组的时候,编译器会直接将它转化为指针,其中在使用数组名时,编译器会自动转化为数组首元素的地址。
int ia[] = {1, 2, 3, 4, 5};
auto ia2 = ia;
ia2[2] = 10; // 这里ia2是指向ia数组首元素的指针,这里其实是在修改ia第3个元素的值
需要注意的是在使用decltype时,该现象不会发生,decltype只会根据表达式推断出类型,而不会具体计算表达式的值,所以它遇到数组名时,根据上下文知道它是一个数组,而不会实际取得数组首元素的地址
int ia[] = {1, 2, 3, 4, 5};
decltype(ia) ia2 = {0}; //这里ia2 是一个独立的数组,与ia无关
ia2[2] = 10;
指针也可以看作迭代器的一种,进行迭代时终止条件是数组尾元素下一个位置的地址
int ai[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int *pbegin = &ai[0];
int *pend = &ai[10];
for(int* it = pbegin; it != pend; it++)
{
cout << *it << endl;
}
c++ 11中引入两个函数来获取数组的begin位置和end位置,分别为begin() 与 end()
int ai[] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9};
for(int *p = begin(ai); p != end(ai); p++)
{
cout << *p << endl;
}
c 风格的字符串
string转化为char* 可以使用string.c_str()函数,该函数返回的是const char*,以取保无法通过这个指针修改字符串本身的值,另外该函数返回的地址一直有效,如果后续修改了string的值,那么根据字符串的算法,字符串中保存字符的地址可能发生变化,此时再使用原来返回的指针访问新的字符串,可能会出现问题
如果执行完c_str函数后,程序想一直访问其返回的数组,最好将该数组重新拷贝一份
string s = "hello world";
const char* pszBuf = s.c_str()
char* pBuff = new char[s.size() + 1];
memset(pBuff, 0x00, sizeof(char) * s.size() + 1);
strcpy(pBuff, pszBuff);
//后面可以直接使用pbuf,即使s字符串改变
s = "boy next door";
//do something
delete[] pBuf;
为了与旧代码兼容,允许使用数组来初始化一个vector容器,只需要指明需要拷贝的首元素地址和尾元素地址
int arr[] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ,10};
vector<int> va(begin(arr), end(arr));
多维数组
多维数组是数组的数组,数组中每一个成员都是一个数组。当一个数组的元素仍是数组时,需要多个维度来表示,一个表示数组本身的大小,一个维度表示元素中数组大小
对于二维数组来说,一般把第一个维度称之为行,第二个维度称之为列。
int ia[3][4] = {
{0, 1, 2, 3},
{4, 5, 6, 7},
{8, 9, 10, 11}
};
//等价于
int ia[3][4] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11};
多维数组的初始化可以用打括号初始化每个维度的数据,也可以省略中间的大括号,这样它会按照顺序初始化
但是需要注意
int ia[3][4] = {
{0},
{1, 2},
{3, 4, 5}
};
int ia[3][4] = {0, 1, 2, 3, 4, 5};
上述代码中,二者含义完全不一样,上一个表示每个子元素中的数组如何初始化,最终结果为{0, 0, 0, 0, 1, 2, 0, 0, 3, 4, 5, 0}。下面一个是从第一行开始依次初始化所有元素,最终结果为{0, 1, 2, 3, 4, 5, 0, 0, 0, 0, 0, 0}
可以使用下标访问数组元素,一个维度对应一个下标
int ai[3][4] = {0};
cout << ai[2][3] << endl; //如果下标个数和数组维度一样,将得到具体类型的值
cout << ai[2] << endl; //下标数小于数组维度,得到对应子数组的首地址
可以使用for循环遍历数组
int a[3][4] = {0};
for(auto row : a){
for(auto i : row) //错误不能对指针使用迭代
{
cout << i << endl;
}
}
上述例子中,由于多维数组中存储的是数组元素,所以row默认是数组元素,也就是数组首地址,是指针类型,也就不能使用内层的迭代了
我们可以稍微做一些修改
int a[3][4] = {0};
for(auto& row : a){
for(auto i : row) //错误不能对指针使用迭代
{
cout << i << endl;
}
}
使用引用声明之后,row就表示指向内层子数组的一个数组的引用,也就是一个子数组本身,针对数组就可以使用范围for了
注意:使用for范围遍历时,除了最内层元素,其余的都需要声明为引用类型
多维数组的名称也是数组的首地址
定义多维数组的指针时,需要明确,多维数组是存储数组的特殊数组
int ai[3][4] = {0};
int (*p)[4] = ai;
// int *p[4] 表示的是指针数组,数组有4个成员,每个成员都是一个int*
上述代码,ai是一个存储3个数组元素的数组,每个元素又是存储4个整型元素的数组,因此定义它的指针的时候,需要明确,指针类型应该是数组元素的类型,也就是有4个int型元素的数组的指针
当然如果嫌麻烦或者不会写,可以使用auto来定义
一般来说,书写多维数组的指针是比较麻烦的一件事,可以使用类型别名让它变得简单点,上面的例子可以改写一下
//typedef int int_array_4[4]; 二者是完全等价的
using int_array_4 = int[4];
int_array_4 *pArr = ai;
for(; pArr != ai + 3; ++pArr)
{
for(int *p = *pArr; p != *pArr+4; ++p)
{
cout << *p << " ";
}
cout << endl;
}
数组名代表的是数组的首元素,多维数组又可以看作是一个存储数组的数组。所以这里ai的名称代表的是一个存储了3个元素的数组,每个元素都是存储4个整型数据的数组。
pArr 的类型是存储了4个整型元素的数组的指针,所以这里与ai表示的指针的类型相同。这里我们将ai的值赋值给指针。在循环中,外层循环用来找到ai数组中每个子数组的指针。
内层循环中,使用pArr解引用得到指针指向的每一个对象,也就是一个存储了4个整型元素的数组。针对这个数组进行循环,依次取出数组中每一个元素。