33.1 std::string

内部实现 许多string库的实现结构包含一个指向字符串缓冲区的指针,一个包含当前字符串长度的变量以及一个表示当前字符串缓冲区大小的变量。为了能够将缓冲区指针传递给使用ASCII字符串的函数,通常string缓冲区中的字符串以0结尾。 C++标准中没有规定std::string应该如何实现,因此通常是按照上述方式实现的。 按照规定,std::string应该是一个模板而不是类,以便能够支持不同的字符类型,如char、wchar_t等。

对于std::string,MSVC和GCC中的内部实现存在差异,下面依次进行说明

MSVC

MSVC的实现中,字符串存储在适当的位置,不一定位于指针指向的缓冲区(如果字符串的长度小于16个字符)。 这意味着短的字符串在32位环境下至少占据16+4+4=24字节的空间,在64位环境下至少占据16+8+8=32字节,当字符串长度大于16字符时,相应的需要增加字符串自身的长度。

  1. #include <string>
  2. #include <stdio.h>
  3. struct std_string
  4. {
  5. union
  6. {
  7. char buf[16];
  8. char* ptr;
  9. } u;
  10. size_t size; // AKA ’Mysize’ in MSVC
  11. size_t capacity; // AKA ’Myres’ in MSVC
  12. };
  13. void dump_std_string(std::string s)
  14. {
  15. struct std_string *p=(struct std_string*)&s;
  16. printf ("[%s] size:%d capacity:%d\n", p->size>16 ? p->u.ptr : p->u.buf, p->size, p->
  17. capacity);
  18. };
  19. int main()
  20. {
  21. std::string s1="short string";
  22. std::string s2="string longer that 16 bytes";
  23. dump_std_string(s1);
  24. dump_std_string(s2);
  25. // that works without using c_str()
  26. printf ("%s\n", &s1);
  27. printf ("%s\n", s2);
  28. };

通过源代码可以清晰的看到这些。 如果字符串长度小于16个符号,存储字符的缓冲区不需要在堆上分配。实际上非常适宜这样做,因为大量的字符串确实都较短。显然,微软的开发人员认为16个字符是好的临界点。 在main函数尾部,虽然没有使用c_str()方法,但是如果编译运行上面的代码,所有字符串都将打印在控制台上。 当字符串的长度小于16个字符时,存储字符串的缓冲区位于std::string对象的开始位置,printf函数将指针当做指向以0结尾的字符数组,因此上述代码可以正常运行。 第二个超过16字符的字符串的打印方式比较危险,通常程序员犯的错误是忘记写c_str()。这在很长的一段时间不会引起人的注意,直到一个很长的字符串出现,然后程序崩溃。而上述代码可以工作,因为指向字符串缓冲区的指针位于结构体的开始。

GCC

GCC的实现中,增加了一个引用计数, 一个有趣的事实是一个指向std::string类实例的指针并不是指向结构体的起始位置,而是指向缓冲区的指针,在libstdc++-v3_string.h,中我们可以看到这主要是为了方便调试。

The reason you want Mdata pointing to the character %array and not the Rep is so that the debugger can see the string contents. (Probably we should add a non-inline member to get the Rep for the debugger to use, so users can check the actual string length.)

在我的例子中将考虑这一点:

  1. #include <string>
  2. #include <stdio.h>
  3. struct std_string
  4. {
  5. size_t length;
  6. size_t capacity;
  7. size_t refcount;
  8. };
  9. void dump_std_string(std::string s)
  10. {
  11. char *p1=*(char**)&s; // GCC type checking workaround
  12. struct std_string *p2=(struct std_string*)(p1-sizeof(struct std_string));
  13. printf ("[%s] size:%d capacity:%d\n", p1, p2->length, p2->capacity);
  14. };
  15. int main()
  16. {
  17. std::string s1="short string";
  18. std::string s2="string longer that 16 bytes";
  19. dump_std_string(s1);
  20. dump_std_string(s2);
  21. // GCC type checking workaround:
  22. printf ("%s\n", *(char**)&s1);
  23. printf ("%s\n", *(char**)&s2);
  24. };

由于GCC有较强的类型检查,因此需要技巧来隐藏类似之前的错误,即使不使用c_str(),printf也能够正常工作。

更复杂的例子

  1. #include <string>
  2. #include <stdio.h>
  3. int main()
  4. {
  5. std::string s1="Hello, ";
  6. std::string s2="world!\n";
  7. std::string s3=s1+s2;
  8. printf ("%s\n", s3.c_str());
  9. }
  1. $SG39512 DB Hello, ’, 00H
  2. $SG39514 DB world!’, 0aH, 00H
  3. $SG39581 DB ’%s’, 0aH, 00H
  4. _s2$ = -72 ; size = 24
  5. _s3$ = -48 ; size = 24
  6. _s1$ = -24 ; size = 24
  7. _main PROC
  8. sub esp, 72 ; 00000048H
  9. push 7
  10. push OFFSET $SG39512
  11. lea ecx, DWORD PTR _s1$[esp+80]
  12. mov DWORD PTR _s1$[esp+100], 15 ; 0000000fH
  13. mov DWORD PTR _s1$[esp+96], 0
  14. mov BYTE PTR _s1$[esp+80], 0
  15. call ?assign@?$basic_string@DU?$char_traits@D@std@@V?
  16. $allocator@D@2@@std@@QAEAAV12@PBDI@Z ; std::basic_string<char,std::char_traits<char>,std::
  17. allocator<char> >::assign
  18. push 7
  19. push OFFSET $SG39514
  20. lea ecx, DWORD PTR _s2$[esp+80]
  21. mov DWORD PTR _s2$[esp+100], 15 ; 0000000fH
  22. mov DWORD PTR _s2$[esp+96], 0
  23. mov BYTE PTR _s2$[esp+80], 0
  24. call ?assign@?$basic_string@DU?$char_traits@D@std@@V?
  25. $allocator@D@2@@std@@QAEAAV12@PBDI@Z ; std::basic_string<char,std::char_traits<char>,std::
  26. allocator<char> >::assign
  27. lea eax, DWORD PTR _s2$[esp+72]
  28. push eax
  29. lea eax, DWORD PTR _s1$[esp+76]
  30. push eax
  31. lea eax, DWORD PTR _s3$[esp+80]
  32. push eax
  33. call ??$?HDU?$char_traits@D@std@@V?$allocator@D@1@@std@@YA?AV?$basic_string@DU?
  34. $char_traits@D@std@@V?$allocator@D@2@@0@ABV10@0@Z ; std::operator+<char,std::char_traits<char
  35. >,std::allocator<char> >
  36. ; inlined c_str() method:
  37. cmp DWORD PTR _s3$[esp+104], 16 ; 00000010H
  38. lea eax, DWORD PTR _s3$[esp+84]
  39. cmovae eax, DWORD PTR _s3$[esp+84]
  40. push eax
  41. push OFFSET $SG39581
  42. call _printf
  43. add esp, 20 ; 00000014H
  44. cmp DWORD PTR _s3$[esp+92], 16 ; 00000010H
  45. jb SHORT $LN119@main
  46. push DWORD PTR _s3$[esp+72]
  47. call ??3@YAXPAX@Z ; operator delete
  48. add esp, 4
  49. $LN119@main:
  50. cmp DWORD PTR _s2$[esp+92], 16 ; 00000010H
  51. mov DWORD PTR _s3$[esp+92], 15 ; 0000000fH
  52. mov DWORD PTR _s3$[esp+88], 0
  53. mov BYTE PTR _s3$[esp+72], 0
  54. jb SHORT $LN151@main
  55. push DWORD PTR _s2$[esp+72]
  56. call ??3@YAXPAX@Z ; operator delete
  57. add esp, 4
  58. $LN151@main:
  59. cmp DWORD PTR _s1$[esp+92], 16 ; 00000010H
  60. mov DWORD PTR _s2$[esp+92], 15 ; 0000000fH
  61. mov DWORD PTR _s2$[esp+88], 0
  62. mov BYTE PTR _s2$[esp+72], 0
  63. jb SHORT $LN195@main
  64. push DWORD PTR _s1$[esp+72]
  65. call ??3@YAXPAX@Z ; operator delete
  66. add esp, 4
  67. $LN195@main:
  68. xor eax, eax
  69. add esp, 72 ; 00000048H
  70. ret 0
  71. _main ENDP

编译器并不是静态构造string对象,存储数据的缓冲区是否一定要在堆中呢?通常以0结尾的ASCII字符串存储在数据节中,然后运行时,通过赋值方法完成s1和s2两个string对象的构造。通过+操作符,s3完成string对象的构造。 可以注意到上述代码中并没有c_str()方法的调用,这是因为由于函数太小,编译器将其内联了,如果一个字符串小于16个字符,eax寄存器中存放指向缓冲区的指针,否则,存放指向堆中字符串缓冲区的指针。 然后,我们看到了三个析构函数的调用,当字符串长度超过16字符时,析构函数将被调用,在堆中的缓冲区会被释放。此外,由于三个std::string对象都存储在栈中,当函数结束时,他们将被自动释放。 可以得到一个结论,短的字符串对象处理起来更快,因为堆访问操作较少。 GCC生成的代码甚至更简单(正如我之前提到的,GCC并不将短的字符串存储在结构体中)

  1. .LC0:
  2. .string "Hello, "
  3. .LC1:
  4. .string "world!\n"
  5. main:
  6. push ebp
  7. mov ebp, esp
  8. push edi
  9. push esi
  10. push ebx
  11. and esp, -16
  12. sub esp, 32
  13. lea ebx, [esp+28]
  14. lea edi, [esp+20]
  15. mov DWORD PTR [esp+8], ebx
  16. lea esi, [esp+24]
  17. mov DWORD PTR [esp+4], OFFSET FLAT:.LC0
  18. mov DWORD PTR [esp], edi
  19. call _ZNSsC1EPKcRKSaIcE
  20. mov DWORD PTR [esp+8], ebx
  21. mov DWORD PTR [esp+4], OFFSET FLAT:.LC1
  22. mov DWORD PTR [esp], esi
  23. call _ZNSsC1EPKcRKSaIcE
  24. mov DWORD PTR [esp+4], edi
  25. mov DWORD PTR [esp], ebx
  26. call _ZNSsC1ERKSs
  27. mov DWORD PTR [esp+4], esi
  28. mov DWORD PTR [esp], ebx
  29. call _ZNSs6appendERKSs
  30. ; inlined c_str():
  31. mov eax, DWORD PTR [esp+28]
  32. mov DWORD PTR [esp], eax
  33. call puts
  34. mov eax, DWORD PTR [esp+28]
  35. lea ebx, [esp+19]
  36. mov DWORD PTR [esp+4], ebx
  37. sub eax, 12
  38. mov DWORD PTR [esp], eax
  39. call _ZNSs4_Rep10_M_disposeERKSaIcE
  40. mov eax, DWORD PTR [esp+24]
  41. mov DWORD PTR [esp+4], ebx
  42. sub eax, 12
  43. mov DWORD PTR [esp], eax
  44. call _ZNSs4_Rep10_M_disposeERKSaIcE
  45. mov eax, DWORD PTR [esp+20]
  46. mov DWORD PTR [esp+4], ebx
  47. sub eax, 12
  48. mov DWORD PTR [esp], eax
  49. call _ZNSs4_Rep10_M_disposeERKSaIcE
  50. lea esp, [ebp-12]
  51. xor eax, eax
  52. pop ebx
  53. pop esi
  54. pop edi
  55. pop ebp
  56. ret

可以看到传递给析构函数的并不是一个对象的指针,而是在对象所在位置的前12个字节的位置,也就是结构体的真正起始位置。