【纯纯干货直接拉到 “C 的字符串和指针” 部分】
首先交代一下这个问题的背景。
我们要做的是一个类似于字符串拼接的工作。实际是把一个关系数据库内,来自不同表的不同record拼接起来,就是所谓的join操作。
在这个数据库里,所有的 record 是以 void* 的形式存储的。很多朋友可能不理解数据库存储的方式,简单说一下。比如一个数据库,它的表头是(id,name,age),假如说有一条record是(1,“Liming”,17)。这一条 record 里面的三个数字,是完完全全以首尾相连的字节形式存储在磁盘上的。也就是说,机器只认你这块的二进制码,不在乎你是什么类型。类型是程序语言设计的,存储设备是不知道的。只是程序语言把从存储设备里读出来的东西,按照它的做法变成了某种类型让人操作而已。
在insert的时候,先声明一个临时的 char* 用来存储这些字节,之后把每一个字段的值memcpy到这个char* 里面(每个字段的长度都是建表的时候规定好的),最后把这个临时的 char* 赋值给实际用于存储的 void*。
我们要做的第一件事,是把这些字节读进一个vector
字符串从C到C++
C风格字符串就是一个char*,而C++风格字符串是一个string。string其实是一个容器,它里面封装的其实就是一个 char*,但是因为封装成 string,就可以用在很多STL的算法里。C++ Primer把string和ector放在一起去讲的,原因就是它们都是C 里面不能变长的一些东西的封装(字符串和数组,注意C是没有字符串类型的)。
先来点基本的:
int main(){
cout << sizeof(char) << endl; //1
cout << sizeof(char*) << endl; //8
cout << sizeof(int*) << endl; //8
cout << sizeof(string) << endl; //32
char* a = "123";
cout << "*a的值是 " << *a << endl; //1
cout << "*a的size是 " << sizeof(*a) << endl; //1
cout << "a的值是 " << a << endl; //123
cout << "a的size是 " << sizeof(a) << endl; //8
string b = a; // string b = "123"; 这两句等价
cout << "b的值是 " << b << endl; //123
cout << "b的size是 " << sizeof(b) << endl; //32
}
逐句分析以上代码:
- char 类型数据在内存中占据1个字节。
- 任何指针类型在内存中占据8个字节。我的机器是64位的,32位就是4咯。指针的值是一个地址,地址长度就是 CPU 里面寄存器的数据宽度(现代计算机的核心就是“取值-执行”,寄存器里存的是要操作数据的地址)。这也就是所谓机器字长,即计算机进行一次整数运算所能处理的二进制数据的位数。机器字长通常就是 CPU 内部数据通道的宽度,这是效率最大化的。一个能一次处理64位的机器,但是数据通道一次只能运输32位,那就得运输两次才能处理一次。数据库里也有类似的思想,数据在内存中以 4KB 大小的 page 存储,因此 buffer pool 和 disk 间的 I/O 大小就是 4kb,一次刚好取一页。
- string 类型数据在内存中占据32个字节。string 是一个类,它里面封装了一个 char* 的指针,其实还有别的其它数据成员。这32个字节里有8个属于 char*,其它24个字节我们也不需要知道是啥。它的 sizeof 绝对不是它存的那个字符串的 sizeof。
- C语言中字符串实际是以字符数组(char[])的形式保存的。char * a 这个 a,就被视为数组名。对数组名解引用,得到的就是 a[0] 的值,即1。这个1是个存在字符数组里的东西,因此是个 char 类型,它的 sizeof 就是1。
- C/C++里的 len 才是真正获取字符串长度的东西。两种语言的 len 的值都是3,说明C和C++取字符串长度都不计算字符串末尾的 。
继续看:
int main(){
char a[] = {'1', '2', ' ', '3', '4'}; //写成char* 会直接error
char b[] = "12 34"; //写成char* 会报warning
cout << "a的len是 " << strlen(a) << endl; //2
cout << "b的len是 " << strlen(b) << endl; //3
cout << "a的值是 " << a << endl; //12
cout << "b的值是 " << b << endl; //12
}
- 大括号初始化 char a[] 没问题,如果用大括号初始化 char * 就会报【Error】scalar object 'a' requires one element in initializer,也就是说列表初始化的对象一定要是一个“组”,不管是数组还是容器。初始化 char* 扔到 .c文件能通过编译,会报【Warrning】,但是这个 a 是不可用的。尽管C并没有列表初始化,但它也只支持用大括号去初始化数组,不能用这玩意初始化指针。
- strlen() 是C里用来计算长度的函数,截止到 ,这玩意在C++里对应的是 string 类的 length() 方法。那明明大家都是2后面跟个 ,为啥 a 和 b 的长度不相等呢?
- 都知道 C 字符串会在末位加个 ,也就是说 a 实际存的时候占了6个字节的空间,4后面还有一个自动填上的 。这东西的作用就是不需要搞长度了,编译器读到 就自动认为当前字符串已经结束。那这就造成了整个字符串使用过程中最大的麻烦:字符串里一旦有我们手动添加的 ,编译器不知道这个是不是它添加的就直接截断了。毕竟设计这个事的人可能觉着正常人是没有闲着没事往字符串里加 的。所以 a 被截断了,长度就是2。
- 为啥 b 的长度就是3呢?因为编译器看到"12 34",根本就不认为是12和34之间加了个 ,因为有个双引号转义字符叫 34。所以实际上编译器认为b 是这样的 ‘1’ ‘2’ ‘ 34’ ‘ ’。因此 len 长度是3,毕竟编译器唯爱 。那读b+4的值就是未定义的行为了,溢出了。要注意, 34 这个玩意儿是存在一个 char 里的,我现在要把两个字符串拼一起(memcpy),会不会出现第一串尾巴的 和后一串开头可能会出现的34碰面消了的情况呢?不会。因为 独占一个字节,3和4各占一个字节,字符串的copy都是按字节copy的。就是说假如说我输入 34 会出现二义性,是因为我这东西要经过编译器解析,编译器它设计解析树的时候就没办法避免这个事。但是copy,直接走字节,全是0和1,这玩意连意义都没有,更别提有什么二义的可能。
再继续:
int main(){
char a[] = {'1', '2', ' ', '3', '4'}; //2
cout << strlen(a) << endl;
string s = {'1', '2', ' ', '3', '4'}; //5
cout << s.length() << endl;
cout << s << endl; //1234
}
为什么string里遇到了 ,还是能完整读出1234,长度也是5?有人会说,这是因为 C++ 的 string 不以 结束。是这样的吗?
int main(){
string s("12345");
for (int i = 0; i <= s.size()+3; i++){
cout << s[i];
if(s[i] == ' '){cout << '-';}
else{cout << '*';} //GCC9.2.0 : 1*2*3*4*5*---*
} //msvc160 : 1*2*3*4*5*-
}
如果按照设想,这个字符串的 size 是5,也就是下标从0-4,往后读就越界了。结果这里直接疯狂越界,还都能读。我把 size() +3 随便改了个 +8 ,读出来一大堆乱七八糟的东西1*2*3*4*5*---**@*---。
当前的编译器是 GCC 9.2,只能说在这个编译环境下,string 对象可以无限越界。
当我把编译环境切换为 VS2019 时,输出结果就很让人满意了1*2*3*4*5*-,并且提示下标越界。也就是说 MSVC 在实作的时候,是在 string 对象的结尾加上了 的。
切换回 GCC 9.2,因为这块没办法检验下标越界其实也不能得出很肯定的结论,只是在我测试了很多不同字符串之后,发现最后一个 * 后面全部都是 - 。那只能根据这个现象做个推断呗:string 对象是以 结尾的 。
当然,我也心生一计,据说 C 的字符串操作函数只能接受两个结尾是 的字符串(C style 字符串)作为参数,那我拿两个 string 对象试试不就知道了?注意,string 里不止有一个字符串!它还有别的东西!只能转成 c_str 放进去!
但不管怎么说,string 里什么东西都能放,没有像 C 字符串那种放不了 的禁忌。
字符串的拷贝
首先看 C 的字符串拼接:
(1) strcpy:到 为止,目标地址一定要已经被分配了足够的空间
char a[10] = {'1', '2', ' ', '3', '4'};
char c[10];
char d[10];
strcpy(c, a);
strncpy(d, a, strlen(a));
cout << strlen(a) << endl; //2
cout << c << endl; //12
cout << d << endl; //12@,msvc里就是12
(2) strcat:到 为止(去掉了第一个参数尾巴的 )
char a[10] = {'1', '2', ' ', '3', '4'};
char b[10] = {'5', '6', ' ', '7', '8'};
strcat(b, a);
cout << b << endl; //5612
(3) strdup:用来向没有提前分配好空间的指针复制,内部先malloc空间后复制,需要手动free
char a[10] = {'1', '2', ' ', '3', '4'};
char *c;
c = strdup(a); //strcpy(c, a); 会失败
cout << c << endl;
free(c); //防止内存泄漏
(4) memcpy:不是字符串操作函数,而是直接操作内存,可以移植 ,需要目标地址足够大
先看个简单的:
char a[10] = "nb c"; char b[10] = "edg"; cout << strlen(a) << endl; //2,这导致 c根本没被copy过来 memcpy(b + strlen(b), a , strlen(a)); cout << b ; //edgnb
稍加改动:
char a[10] = "nb c"; char b[10] = "edg"; memcpy(b + strlen(b), a , 4); cout << b ; //edgnb cout << b+4 << endl; //b cout << b+5 << endl; //空 cout << b+6 << endl; //c
尽管将 memcpy 的最后一个参数从2改成了4,为什么还是只能读出 edgnb?这是读的问题,读 b 的时候看到 直接截止,后面的 c 没被读出来。但没读出来并不意味着没 copy 过来,我们读 b+5,正好读到这个 ,读 b+6,就能读出 c。
再做改动:
char a[10] = "nb cd"; char b[10] = "edg"; memcpy(b + strlen(b) + 1, a , 5); //这里第一个参数加了1,最后一个参数加1是因为这个例子里的a变长了,和要测试的内容无关 cout << b << endl; //edg cout << b+3 << endl; //空 cout << b+4 << endl; //nb cout << b+5 << endl; //b cout << b+6 << endl; //空 cout << b+7 << endl; //cd
上面没有加1,实质是 a copy 过来的时候覆盖了 b 末位的 ,现在咱们+1,就是留下了这个 。因此输出 b,只输出 edg。b+3 是原来 b 串尾部的 ,现在留下来了,b+6 是 a 里人为规定的 ,因此 b+6 也是空。
再来看 C++ 的拼接:
(1)operator+:string重载的加法运算符,要求是加号两侧至少有一个string类型的对象,深入的可以看 C++ Primer
int main(){
char c1[10] = "g ";
char c2[10] = "g h ";
string s1 = "ab cd ef";
string a = s1 + c1;
string b = c1 + s1;
string c = s1 + c2;
cout << a << endl; //ab cdg
cout << b << endl; //g ab cd
cout << c << endl; //ab cdg
cout << s1.length() << endl; //5
cout << a.length() << endl; //7
cout << b.length() << endl; //7
return 0;
}
- s1.length() = 5,说明空格算字符,并且 string 的 length 也是到 为止。
- a 和 c 输出 ab cdg,说明无论是内部包含 的 string 还是内部包含 的 char*,在加法里都只计算第一个 之前的部分。
(2)string::append:和上面的一样,也是两个字符串只取第一个 前面的部分拼接
int main(){
char c[10] = "g h ";
string s = "abcd ef";
s.append(c);
cout << s.length() << endl; //5
cout << s << endl; //abcdg
}
(3)string::push_back:一次只能 push 一个字符进去,参数不能是 char* 或者 string 类型的对象,只能是单引号引的常量
int main(){
string s = "abcd ef";
s.push_back('+');
s.push_back('*asd*se** h');
cout << s.length() << endl; //6
cout << s << endl; //abcd+h
}
注意,push_back 只输出参数里面第一个 之后的第一个字符。
(4)string::insert:第一个参数是实际的位数,比如是4,就在 s[3] 后面进行 insert。
int main(){
string s = "abcd ef";
const char* a = "0v0";
s.insert(4, a, 3);
cout << s.length() << endl; //7
cout << s << endl; //abcd0v0
}
这里 Insert 的参数改成5(在 后面)就会插入失败,也是这个字符串失效了,执行到操作这个字符串的动作的时候程序直接终止。也就是说,s 并没有保存 之后的 e 和 f。为什么会这样,string 里面不是什么都能存吗?
(5)string::replace:替换某一个字符,也是遇 中断,无论是
int main ()
{
std::string str="abc def";
std::string str2="+ +";
str.replace(2,4,str2); //[2,4)左闭右开,从第2个元素即 str[0] 后面开始替换
for(int i = 0; i < 7; ++i)
std::cout << str[i] << std::endl; //输出 a b + 后面全是空字符
}
再来:
int main ()
{
std::string str="abcdef";
std::string str2="+++++";
str.replace(2, 4, str2);
for(int i = 0; i < 7; ++i)
std::cout << str[i] << std::endl; //输出 a b + + + + +
}
因此 replace 会把被拷贝的字符全部复制过来的,而且由于是人为计算区间长度,用的时候这个长度不要算错。
char* 与 string 的转换
回到开头,再看 string 的初始化:
int main(){
string s1 = {'1', '2', ' ', '3', '4'};
string s2 = "12 89"; //为了避免/034转义字符的麻烦
cout << s1.length() << endl; //5
cout << s1 << endl; //1234
cout << s2.length() << endl; //2
cout << s2 << endl; //12
}
第一种正规的初始化没有问题,然而,第二种初始化就会丢失 后面的内容。因为在第二种初始化过程中,发生了隐式的类型转换:from char* to string。在 char* 转换成 string 的过程中,string 只接收第一个 前面的内容,因此 s2 就是 “12”,后面的东西根本没被存进去。其它隐式的类型转换,包括显式的强制类型转换,无论是 C 里的(type)var,还是 C++ 的 static_cast
也就是说,C 风格字符串向 string 类型转换的过程中,一定会丢东西的。有没有不丢东西的做法呢?
int main(){
char s1[10] = {'1', '2', ' ', '3', '4'};
char s2[10] = "12 89";
string a1(s1, 6);
string a2(s2, 6);
cout << a1.length() << endl; //6
cout << a2.length() << endl; //6
cout << s1 << endl; //12
cout << a1 << endl; //1234
cout << a2 << endl; //1289
}
看得出,只要通过指定长度初始化 string 的方式,就可以保证东西不丢。初始化长度是几,length就是几。因此得出结论:string 如果被规定长度就没问题,只有像 C 一样变长的时候,才会按照 规则确认末位。拿 s2 举例,其实存的时候,包括 在内的所有字符全部被留在了内存里,但是由于 规则,取的时候就取丢了, 后面的东西就是不可见的,但是直接操作内存,是没问题的。
那么 string 向 char* 转换呢?
因为 C++ 很多时候要和 C 混合编程,而 string 不能被 C 识别,因此 string 里面设计了 c.str() 方法,可以将 string 变成 char*:
int main(){
string s1 = {'1', '2', ' ', '3', '4'};
string s2 = "12 89";
cout << s1.c_str() << endl; //12
cout << strlen(s1.c_str()) << endl; //2
cout << s2.c_str() << endl; //12
cout << strlen(s2.c_str()) << endl; //2
}
不出所料,string 向 C 风格字符串转换,依旧也出现了 的截断问题,也丢了东西。这是无解的,string 只能通过 c_str 去转换,string 转换成 char*,似乎真的只有这一条路,而这一条路,是一定会丢东西的路。
C 的字符串和指针
下面的内容是本文的顶级精华,玩得明白字符串,可以说 C 的内存就至少玩明白百分之一了。
看第一部分:
int main(){
char *a = "aaa bbb";
char *b = "aaa bbb";
char c[10] = "aaa bbb";
char d[10] = "aaa bbb";
char* e = (char*)malloc(10*sizeof(char));
char* f = (char*)malloc(10*sizeof(char));
e = "aaa bbb";
f = "aaa bbb";
printf("%s, %s, %p, %pn", a, a+4, &a, a);
printf("%s, %s, %p, %pn", b, b+4, &b, b);
printf("%s, %s, %p, %pn", c, c+4, &c, c);
printf("%s, %s, %p, %pn", d, d+4, &d, d);
printf("%s, %s, %p, %pn", e, e+4, &e, e);
printf("%s, %s, %p, %pn", f, f+4, &f, f);
}
输出结果:
aaa, bbb, 00000000007bfe18, 00000000004e6001 aaa, bbb, 00000000007bfe10, 00000000004e6001 aaa, bbb, 00000000007bfe06, 00000000007bfe06 aaa, bbb, 00000000007bfdfc, 00000000007bfdfc aaa, bbb, 00000000007bfdf0, 00000000004e6001 aaa, bbb, 00000000007bfde8, 00000000004e6001
首先明确一个事:C 程序的内存是分成不同区的,比如手动分配地址的变量在堆区,自动分配地址的变量在栈区,还有一个地方用来存储字面量,这就是常量存储区。char* 与 char[] 最大的不同,在于 char* 仅仅申请了一个四个字节的东西用于存放指针,而这个指针所指向的东西(一个字符串字面量/常量)早在编译时就被编译器发现,然后早早地放进了常量存储区。
而尽管为 char[] 赋值的东西也在编译时被编译器发现,但是,在运行时,由于 char[] 在声明的时候已经在栈中开辟了足够的空间,因此它会把常量存储区里的东西原封不动地拷贝一份放到栈里它所申请的内存中。即:用 char[] 存东西,这个常量在内存中存在多份,原版存放在常量存储区,而栈里存放一或多份拷贝版(每个 char[] 都拷贝一次,比如上面的代码就是拷贝了两次,因此就有 2+1 份)。而用 char* 存东西,不管声明了多少个不同名字的 char* ,只要等号右侧的字面量是一样的,那它们都指向这一个地方。
继续看,哪怕是为 char* 在栈中 malloc 了一个内存,也要清楚这件事是如何完成的:首先,程序创建了一个4字节的空间给这个 char*,之后 malloc 一块内存,让这个指针指向这个内存。那么,当这个 char* 被赋值了一个常量,那原本指向 malloc 的指针重新指向了常量存储区里面的地址,而且还会导致 malloc 的空间再也找不到了不能 free 掉。因此这是个非法操作。
因此,以上的结果是很好理解的。当然,读字符串是读到 ,但不意味着这些字符串在存的时候会丢掉手动加入的 后面的东西,因此显式指定地址去读是可以读到 后面的东西的。
对于同一个 char* 变量 a,用 %s 和 %p 输出结果是不一样的。此前我一直会有疑问:为什么字符串明明是一个指针形式,却不能用一个地址初始化?直接输出他自己也不是一个地址?比如现在声明一个 int* 变量,如果我赋值给它,一定要用一个 int 变量的地址。
int i = 3; int* p = &i; cout << &i << endl; cout << p << endl; //肯定相等
但是回过头来看,char* 的赋值是怎么做的?char *a = "aaa bbb";。如果写一个int *i = 3;,那编译器可能直接气到冒烟了。当然,char* 是可以按照上面说的来用的。注意,在测试 char* 的时候尽量多用 printf 的格式化输出,因为 cout 不知道可能是按照什么格式输出的,可能输出一些奇奇怪怪的东西。
char i = '3';
char* p = &i;
printf("%pn", &i);
printf("%pn", p); //肯定相等
上面两段代码的意思是一样的,编译器在编译期寻找程序内的一切常量并放进常量区,运行时程序声明一个 int/char 类型变量,并从常量区拷贝其值到栈中。紧接着声明一个指向 int/char 类型的指针,那这个指针所指的就是栈中拷贝出来的这个成员了。
因此合理推测,C 编译器对一个 char* 的变量有两种解析方法:如果它的右侧是一个地址,就走常规指针的用法,这个指针指向堆栈中的一个变量的地址;如果它的右侧是一个字符串,这个指针就指向常量区。
因此下面这段代码根本就是非法的,a 就是个四字节的指针,往里 memcpy 一个字符串就是变态操作:
int main(){
char *a = "aaa bbb";
char *b = "aaa bbb";
printf("%s, %pn", a, a);
printf("%s, %pn", b, b); //一切正常
memcpy(a, "eee fff", 8);
}
看 vector
int main(){
char *a = "aaa bbb";
char *b = "aaa bbb";
char c[10] = "aaa bbb";
char *d = (char*)malloc(10*sizeof(char));
memcpy(d, "eee fff", 8);
vector v = {};
v.push_back(a);
v.push_back(b);
v.push_back(c);
v.push_back(d);
printf("%p, %sn", a, a);
printf("%p, %sn", b, b);
printf("%p, %sn", c, c);
printf("%p, %sn", d, d);
printf("%p, %sn", v[0], v[0]);
printf("%p, %sn", v[1], v[1]);
printf("%p, %sn", v[2], v[2]);
printf("%p, %sn", v[3], v[3]);
}
输出结果:
00000000004e8001, aaa 00000000004e8001, aaa 00000000007bfdee, aaa 0000000000932450, eee 00000000004e8001, aaa 00000000004e8001, aaa 00000000007bfdee, aaa 0000000000932450, eee
这段代码里出现了三种 char* :
- 第一种是 char*,栈里是一个指向常量区的指针,push 进去的是一个这个指针的拷贝;
- 第二种是 char[],栈里是一个数组,字符串存在栈里,数组首元素的地址可以作为这个字符串的指针(这就是字符数组和字符串的本质区别),push 进去的是一个指向数组首元素指针的拷贝;
- 第三种是 malloc 的 char*,并且直接 memcpy 赋值,这个字符串不在常量区内存在,只在堆里出现。因为是运行时作为 memcpy 的实参出现,编译期看不到。push 进去的是指向这块 malloc 的指针的拷贝。
反正,vector
【END】



