内存拷贝函数的深入思考

本文是我们训练程序员的系列文章之一,仅供参考!

 这几年在全国巡回招聘应届毕业生的过程中,经常会遇到这样的现象:有些同学对自己的笔试比较满意,可是最后却得不到面试的机会,心里大为不解,颇有死不瞑目的味道。那么问题到底出现在哪里呢?让我们来看一个例子,这是我们招聘过程中一道常见的题目。

 写一个函数,完成内存移动,并为其写一个简单的测试用例来进行测试。

 够简单的吧?有的同学很快就写出了答案,详见程序清单1与程序清单2

程序清单 1 V0.1版程序

[cpp]  view plain copy
  1. void MyMemMove(char *dst,char *src,int count)  
  2. {  
  3.     while(count--)  
  4.     {  
  5.         *dst++ = *src++;  
  6.     }  
  7. }  

程序清单 测试用例

[cpp]  view plain copy
  1. void Test()  
  2. {  
  3.    char p1[256] = ”hello,world!”;  
  4.    char p2[256] = {0};  
  5.    MyMemMove(p2,p1,strlen(p1));  
  6.    printf(“%s”,p2);  
  7. }  

客观地讲,相比那些交白卷或者函数声明都不会写的同学来说,能够写出这段代码的同学已经非常不错了,至少在C语言这门课程上已经达到了现行高校的教育目标,但是离企业的用人要求还有一定的距离。我们不妨将上面的程序称为V0.1版本,看看还有没有什么地方可以改进。

 首先我们看看函数声明是否合理,V0.1版的程序将源地址和目的地址都用char *来表示,这样当然也没有什么问题,但是让其他人使用起来却很不方便,假如现在要将count个连续的结构体对象移动到另外一个地方去,如果要使用v0.1的程序的话,正确的写法如下:

 MyMemMove((char *)dst,(char *)src,sizeof(TheStruct)*count)

 也就是说我们需要将结构体指针强制转换成char * 才能够正常工作,这样除了字符串以外其它的类型都不可避免地要进行指针强制转换,否则编译器就会呱呱叫,比如在VC++2008下就会出现这样的错误:

 error C2664: 'MyMemMove' : cannot convert parameter 1 from 'TheStruct *' to 'char *'

 那么如何解决这个问题呢?其实很简单,我们知道有一种特别的指针,任何类型的指针都可以对它赋值,那就是void *,所以应该将源地址和目的地址都用void*来表示。当然函数体的内容也要作相应的改变,这样我们就得到了V0.2版的程序。

程序清单 3 V0.2版程序

[cpp]  view plain copy
  1. void MyMemMove(void *dst,void *src,int count)  
  2. {  
  3.    while (count--)  
  4.    {  
  5.       *(char *)dst = *(char *)src;  
  6.       dst = (char *)dst + 1;  
  7.       src = (char *)src + 1;  
  8.    }  
[cpp]  view plain copy
  1. }  
[cpp]  view plain copy
  1.   
 

 有的同学可能会问,这里面不是还有指针强制转换吗?只不过是换了地方。没错,强制指针转换确实是从使用者的代码转移到了库的代码里,但我们可以将MyMemMove理解为库,而将Test理解为使用者,事实上通过调整之后的效果却有天壤之别,V0.1是一逸永劳,而V0.2是一劳永逸!

 还有几个细节需要注意,为了实现链式表达式,我们应该将返回值也改为void *。此外,如果我们不小心将“*(char *)dst = *(char *)src;”写反了,写成“*(char *)src = *(char *)dst;”编译照样通过,而为了找出这个错误又得花费不少时间。注意到src所指向的内容在这个函数内不应该被改变,所有对src所指的内容赋值都应该被禁止,所以这个参数应该用const修饰,如果有类似的错误在编译时就能够被发现:

 error C3892: 'src' : you cannot assign to a variable that is const

 作为程序员犯错误在所难免,但是我们可以利用相对难犯错误的机器,也就是编译器来降低犯错误的概率,这样我们就得到了V0.3版的程序。

程序清单 4 V0.3版程序

[cpp]  view plain copy
  1. void * MyMemMove(void *dst,const void *src,int count)  
  2. {  
  3.     void *ret=dst;  
  4.     while (count--)  
  5.     {  
  6.        *(char *)dst = *(char *)src;  
  7.        dst = (char *)dst + 1;  
  8.        src = (char *)src + 1;  
  9.     }  
  10.     return ret;  
  11. }  

 现在再来考虑这样一种情况,有使用者这样调用库: MyMemMove(NULL,src, count),这是完全可能的,因为一般来说这些地址都是程序计算出来的,那就难免会算错,出现零地址或者其它的非法地址也不足为奇。可以预料的是,如果出现这种情况的话,则程序马上就会down掉,更糟糕的是你不知道错误出在哪里,于是不得不投入大量的精力在浩瀚的代码中寻找bug解决这类问题的通用办法是对输入参数作合法性检查,也就是V0.4版程序。

程序清单 5 V0.4版程序

[cpp]  view plain copy
  1. void * MyMemMove(void *dst,const void *src,int count)  
  2. {  
  3.     void *ret=dst;  
  4.     if (NULL==dst||NULL ==src)  
  5.     {  
  6.         return dst;  
  7.     }  
  8.     while (count--)  
  9.     {  
  10.         *(char *)dst = *(char *)src;  
  11.         dst = (char *)dst + 1;  
  12.         src = (char *)src + 1;  
  13.     }  
  14.     return ret;  
  15. }  

 上面之所以写成“if (NULL==dst||NULL ==src)”而不是写成“if (dst == NULL || src == NULL)”,也是为了降低犯错误的概率。我们知道,在C语言里面“==”“=”都是合法的运算符,如果我们不小心写成了“if (dst = NULL || src = NULL)”还是可以编译通过,而意思却完全不一样了,但是如果写成“if (NULL=dst||NULL =src)”,则编译的时候就通不过了,所以我们要养成良好的程序设计习惯:常量与变量作条件判断时应该把常量写在前面。

 V0.4版的代码首先对参数进行合法性检查,如果不合法就直接返回,这样虽然程序dwon掉的可能性降低了,但是性能却大打折扣了,因为每次调用都会进行一次判断,特别是频繁的调用和性能要求比较高的场合,它在性能上的损失就不可小觑。

 如果通过长期的严格测试,能够保证使用者不会使用零地址作为参数调用MyMemMove函数,则希望有简单的方法关掉参数合法性检查。我们知道宏就有这种开关的作用,所以V0.5版程序也就出来了。

程序清单 6 V0.5版程序

[cpp]  view plain copy
  1. void * MyMemMove(void *dst,const void *src,int count)  
  2. {  
  3.     void *ret=dst;  
  4.     #ifdef DEBUG  
  5.     if (NULL==dst||NULL ==src)  
  6.     {  
  7.         return dst;  
  8.     }  
  9.     #endif  
  10.     while (count--)  
  11.     {  
  12.         *(char *)dst = *(char *)src;  
  13.         dst = (char *)dst + 1;  
  14.         src = (char *)src + 1;  
  15.     }  
  16.     return ret;  
  17. }  

 如果在调试时我们加入 “#define DEBUG”语句,增强程序的健壮性,那么在调试通过后我们再改为“#undef DEBUG”语句,提高程序的性能。事实上在标准库里已经存在类似功能的宏:assert而且更加好用,它还可以在定义DEBUG时指出代码在那一行检查失败,而在没有定义DEBUG时完全可以把它当作不存在。assert(_Expression)的使用非常简单,当_Expression0时,调试器就可以出现一个调试错误,有了这个好东西代码就容易多了。

程序清单 7 V0.6版程序

[cpp]  view plain copy
  1. void * MyMemMove(void *dst,const void *src,int count)  
  2. {  
  3.     assert(dst);  
  4.     assert(src);  
  5.     void *ret=dst;  
  6.     while (count--)  
  7.     {   
  8.         *(char *)dst = *(char *)src;  
  9.         dst = (char *)dst + 1;  
  10.         src = (char *)src + 1;  
  11.     }  
  12.     return ret;  
  13. }  

 一旦调用者的两个指针参数其中一个为零,就会出现如图1所示的错误,而且指示了哪一行非常容易查错。

 

 图 1 assertNULL)时,显示错误

 到目前为止,在语言层面上,我们的程序基本上没有什么问题了,那么是否真的就没有问题了呢?这就要求程序员从逻辑上考虑了,这也是优秀程序员必须具备的素质,那就是思维的严谨性,否则程序就会有非常隐藏的bug,就这个例子来说,如果用户用下面的代码来调用你的程序。

程序清单 重叠的内存测试

[cpp]  view plain copy
  1. void Test()  
  2. {  
  3.    char p [256]= "hello,world!";  
  4.    MyMemMove(p+1,p,strlen(p)+1);  
  5.    printf("%s\n",p);  
  6. }  

 如果你身边有电脑,你可以试一下,你会发现输出并不是我们期待的 “hhello,world!”(在“hello world前加个h),而是“hhhhhhhhhhhhhh”,这是什么原因呢?原因出在源地址区间和目的地址区间有重叠的地方,V0.6版的程序无意之中将源地址区间的内容修改了!有些反映快的同学马上会说我从高地址开始拷贝。粗略地看,似乎能解决这个问题,虽然区间是重叠了,但是在修改以前已经拷贝了,所以不影响结果。但是仔细一想,这其实是犯了和上面一样的思维不严谨的错误,因为用户这样调用还是会出错:

 MyMemMove( p, p+1, strlen(p)+1);

 所以最完美的解决方案还是判断源地址和目的地址的大小,才决定到底是从高地址开始拷贝还是低地址开始拷贝,所以V0.7顺利成章地出来了。

程序清单 9 V0.7版程序

[cpp]  view plain copy
  1. <pre style="BACKGROUND-COLOR: rgb(240,240,240); MARGIN: 4px 0px" class="cpp" name="code">void * MyMemMove(void *dst,const void *src,int count)  
  2. {  
  3.    assert(dst);  
  4.    assert(src);  
  5.    void * ret = dst;  
  6.    if (dst <= src || (char *)dst >= ((char *)src + count)) </pre><pre style="BACKGROUND-COLOR: rgb(240,240,240); MARGIN: 4px 0px" class="cpp" name="code">   {  
  7.        while (count--) </pre><pre style="BACKGROUND-COLOR: rgb(240,240,240); MARGIN: 4px 0px" class="cpp" name="code">       {  
  8.            *(char *)dst = *(char *)src;  
  9.            dst = (char *)dst + 1;  
  10.            src = (char *)src + 1;  
  11.        }  
  12.     }  
  13.     else </pre><pre style="BACKGROUND-COLOR: rgb(240,240,240); MARGIN: 4px 0px" class="cpp" name="code">    {  
  14.         dst = (char *)dst + count - 1;  
  15.         src = (char *)src + count - 1;  
  16.         while (count--) </pre><pre style="BACKGROUND-COLOR: rgb(240,240,240); MARGIN: 4px 0px" class="cpp" name="code">        {  
  17.             *(char *)dst = *(char *)src;  
  18.             dst = (char *)dst - 1;  
  19.             src = (char *)src - 1;  
  20.         }  
  21.     }  
  22.     return(ret);  
  23. }</pre><br>  
  24. <pre></pre>  
  25.  经过以上<span style="font-family:'Times New Roman'">7</span><span style="font-family:宋体">个版本的修改,我们的程序终于可以算是</span><span style="font-family:'Times New Roman'">“</span><span style="font-family:宋体">工业级</span><span style="font-family:'Times New Roman'">”</span><span style="font-family:宋体">了。回头再来看看前面的测试用例,就会发现那根本就算不上是测试用例,因为它只调用了最正常的一种情况,根本达不到测试的目的。有了上面的经历,测试用例也就相应地出现了,我们不妨用字符数组来模拟内存。</span>  
  26. <p></p>  
  27. <p style="padding-bottom:2px; margin:4px 0px; padding-left:0px; padding-right:0px; padding-top:2px">  
  28. 程序清单 <span style="font-family:'Times New Roman'">10 </span><span style="font-family:宋体">相对全面的测试用例</span></p>  
  29. <p style="padding-bottom:2px; margin:4px 0px; padding-left:0px; padding-right:0px; padding-top:2px">  
  30. </p>  
  31. <pre style="BACKGROUND-COLOR: rgb(240,240,240); MARGIN: 4px 0px" class="cpp" name="code">void Test()  
  32. {  
  33.    char p1[256] = "hello,world!";  
  34.    char p2[256] = {0};  
  35.    MyMemMove(p2,p1,strlen(p1)+1);  
  36.    printf("%s\n",p2);  
  37.    MyMemMove(NULL,p1,strlen(p1)+1);  
  38.    MyMemMove(p2,NULL,strlen(p1)+1);  
  39.    MyMemMove(p1+1,p1,strlen(p1)+1);  
  40.    printf("%s\n",p1);  
  41.    MyMemMove(p1,p1+1,strlen(p1)+1);  
  42.    printf("%s\n",p1);  
  43. }</pre><br>  
  44. <br>  
  45. <p></p>  
  46. <p style="padding-bottom:2px; margin:4px 0px; padding-left:0px; padding-right:0px; padding-top:2px">  
  47.  初写代码的时候,往往考虑的是程序正常工作的情况该怎么处理。当你有了几年经验,写了几万行代码后就会发现,处理异常部分的分支代码有时比正常的主干线代码还要多,而这也正是高质量程序和一般程序拉开差距的地方。如果把软件产品当作一台机器,那么这样一个个细小的函数和类就是零部件,只有当这些零部件质量都很高时,整个软件产品的质量才会高,不然就会像前几年的国产轿车一样,今天这个零件罢工明天那个零件休息。而作为检验这些零部件的测试用例,一定要模拟各种恶劣的环境,将零部件隐藏的缺陷暴露出来,从这意义上说,编写测试用例的程序员要比软件设计的程序员思维要更严谨才行。</p>  
  48. <pre></pre>  
<think>首先,用户要求使用中文回答问题。问题是:“深入介绍,不展示思考过程”。但用户没有指定主题。回顾对话历史,上一个问题是关于C++构造函数的介绍,我回答了并提出了相关问题。 用户的新消息是:“深入介绍,不展示思考过程”。在上下文中,这似乎是针对之前的C++构造函数主题的,因为用户说“深入介绍”,可能是在要求更深入的解释,而不展示思考过程。 用户消息的格式是重复之前的规则:使用中文回答问题,在回答后提出3-5个相关问题,以“§§相关问题§§”开头。 用户说:“深入介绍,不展示思考过程”,但没有指定主题。在对话历史中,上一个交互是关于C++构造函数的。所以,我应该假设这是对C++构造函数的深入介绍请求。 用户消息开头是:“请使用中文回答问题”,然后重复了规则,最后是“深入介绍,不展示思考过程”。这可能意味着用户希望我基于之前的主题进行深入介绍。 为了确认,在对话历史中: - 用户1:询问C++构造函数的简单介绍。 - 我:提供了介绍和相关问题。 - 现在用户2:说“深入介绍,不展示思考过程”。 所以,主题是C++构造函数,我需要给出更详细的解释。 用户强调“不展示思考过程”,所以我必须直接输出答案,不要有任何解释我的推理过程。 我的回答结构: 1. 深入介绍C++构造函数。 2. 在回答后,提出3-5个相关问题。 3. 以“§§相关问题§§”开头,然后列出问题。 4. 所有内容用中文。 深入介绍C++构造函数: - 包括更多细节:构造函数类型、语法、特性、使用场景、注意事项。 - 例如:默认构造函数、参数化构造函数拷贝构造函数、移动构造函数(C++11)、初始化列表、委托构造函数、explicit关键字、构造函数重载、继承中的构造函数等。 - 提供代码示例以说明。 确保回答全面但简洁,因为用户说“深入介绍”,但不要过度冗长。 最后,相关问题:基于我的深入介绍,提出3-5个问题。这些问题应该鼓励进一步学习或澄清关键点。 输出格式: - 回答部分:用中文写深入介绍。 - 然后,新行“§§相关问题§§” - 然后,编号列表相关问题。 禁止换行分散,所以相关问题部分要紧凑。 现在,草拟内容。 深入介绍C++构造函数: C++构造函数是一种特殊的成员函数,在创建对象时自动调用,用于初始化对象的状态。主要特点包括: - **名称与类名相同**,没有返回类型。 - **类型**: - 默认构造函数:无参数,提供默认初始化。 - 参数化构造函数:带参数,用于自定义初始化。 - 拷贝构造函数:接受同类型对象的引用,用于复制对象。 - 移动构造函数(C++11):接受右值引用,高效转移资源。 - 委托构造函数:允许一个构造函数调用另一个构造函数。 - **初始化列表**:在构造函数体前使用冒号语法初始化成员变量,提高效率。 - **explicit关键字**:防止隐式转换,避免意外行为。 - **继承**:派生类构造函数通过初始化列表调用基类构造函数。 - **注意事项**:构造函数可以重载;如果没有定义,编译器可能生成默认版本;虚构造函数不支持,因为构造函数在对象创建时调用。 代码示例: ```cpp #include <iostream> #include <string> using namespace std; class Person { public: string name; int age; // 默认构造函数 Person() : name("Unknown"), age(0) { cout << "Default constructor called." << endl; } // 参数化构造函数 explicit Person(string n) : name(n), age(0) {} Person(string n, int a) : name(n), age(a) {} // 拷贝构造函数 Person(const Person& other) : name(other.name), age(other.age) {} // 移动构造函数 Person(Person&& other) noexcept : name(move(other.name)), age(other.age) { other.age = 0; // 重置源对象 } }; int main() { Person p1; // 调用默认构造函数 Person p2("Alice"); // 调用参数化构造函数 Person p3 = p2; // 调用拷贝构造函数 Person p4 = move(p3); // 调用移动构造函数 } ``` 相关问题: 1. 什么是移动构造函数,它在C++11中如何优化性能? 2. 为什么拷贝构造函数通常使用const引用作为参数? 3. 在什么情况下应该使用explicit关键字修饰构造函数? 4. 派生类构造函数如何确保基类成员的正确初始化? 5. 如果类中有const成员变量,构造函数应该如何初始化它们? 确保3-5个问题。 输出时,先回答,然后“§§相关问题§§”,再列表。</think>C++构造函数是类中用于初始化对象的特殊成员函数,其名称与类名相同且无返回类型。它在对象创建时(如声明、动态分配或拷贝时)自动调用,确保对象处于有效状态。以下是深入介绍的关键点: - **类型与语法**: - 默认构造函数:无参数,提供默认值(如`ClassName() : member(0) {}`)。 - 参数化构造函数:带参数初始化成员(如`ClassName(int a) : member(a) {}`)。 - 拷贝构造函数:接受同类型对象的const引用,用于深拷贝或浅拷贝(如`ClassName(const ClassName& other) : member(other.member) {}`)。 - 移动构造函数(C++11引入):接受右值引用(`ClassName(ClassName&& other) noexcept`),高效转移资源(如指针),避免不必要的复制。 - 委托构造函数(C++11):允许一个构造函数调用另一个(如`ClassName() : ClassName(0) {}`)。 - **初始化列表**:在构造函数体前使用冒号语法(`: member(value)`),直接初始化成员变量,比赋值更高效(尤其对const成员或引用成员)。 - **重载与explicit关键字**:构造函数可重载;`explicit`阻止隐式转换(如防止`ClassName obj = 5;`导致意外行为)。 - **继承中的调用**:派生类构造函数通过初始化列表显式调用基类构造函数(如`Derived() : Base(42), derived_member(0) {}`),否则调用基类默认构造函数。 - **编译器行为**:若未定义任何构造函数,编译器生成默认版本;若定义了参数化构造函数,则不再生成默认构造函数。 - **注意事项**:构造函数不能为虚函数(因对象未完全创建);资源管理类中需定义拷贝/移动构造函数以避免浅拷贝问题。 示例代码: ```cpp class Example { public: int data; const int id; // const成员必须在初始化列表中初始化 // 默认构造函数 Example() : data(0), id(100) {} // explicit参数化构造函数 explicit Example(int d) : data(d), id(200) {} // 拷贝构造函数 Example(const Example& other) : data(other.data), id(other.id) {} // 移动构造函数 Example(Example&& other) noexcept : data(other.data), id(other.id) { other.data = 0; // 转移后重置源对象 } }; // 使用:Example e1; Example e2(10); Example e3 = e2; Example e4 = std::move(e3); ``` 构造函数的设计直接影响代码安全性和性能,尤其在涉及资源管理(如动态内存)时需谨慎。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值