Solmyr 的小品文系列之八:拷贝

类别:编程语言 点击:0 评论:0 推荐:

“zero 帮帮忙吧 ~~ ”

“灿烂”的笑脸,充满诚意的眼神,再加上点头哈腰的姿势,这三者构成了一尊名为“有求于人”的塑像。

在 QQ 上聊的正欢的 zero 抬起头,看着塑像的作者和材料 ——— pisces ,方圆五十米内唯一的女性程序员 ——— 问道:“什么事?”

“我这里有一段 C++ 程序调不通。”

“这类问题你应该去问 Solmyr。”

“哎呀,别开玩笑了,我哪敢去问他呀!总说我笨!上次问他一个小问题,结果又被训的狗血喷头,哼!”,pisces 显得忿忿不平,“还是你来帮帮我吧,我知道你是部门里有数的高手,肯定搞的定的。帮帮忙吧 ~~”

zero 明显的被打动了,于是,在 pisces 的努力下,zero 坐到了 pisces 的计算机前。

“好吧,什么问题?”

“是这样的啦,这里有一组 C 风格的 API ,负责管理设备上的字符通信链接。它们是好些年前设计的”,说着,pisces 调出了一些代码:

// old C style API
typedef int conn_handle;
typedef struct
{
    /* ... 打开链接所需的参数和属性 ... */
}conn_attr;

conn_handle open_conn(conn_attr* p_attr, char* buf, unsigned int buf_size);
void close_conn(conn_handle h);

char read_conn(conn_handle h);
void write_conn(conn_handle h, char c);

...

“枝节的东西不算,主干大概就是这样,一对函数负责打开和关闭,一对函数负责读写。创建链接时候的那个 buf 参数指向一个缓冲区,这个要你自己分配并把长度传进去,和链接一一对应,read_conn/write_conn 会用它做缓冲。我的任务就是写个类把这些 API 包装起来。”,说着 pisces 又调出了另外一段代码:

// pisces' connection class
class connection
{
private:
    conn_attr m_attr;
    bool m_opened;
    int m_bufsize;
    char* m_buf;

    conn_handle m_h;
    ...

public:
    connection(const conn_attr& attr, int bufsize)
    {
        m_attr = attr;
        m_opened = false;
        m_bufsize = bufsize;
        m_buf = new char[m_bufsize];
    }
    ~connection() { delete m_buf; }

    void open()
    {
        m_h = open_conn(&m_attr, m_buf, m_bufsize);
        m_opened = true;
    }
    void close()
    {
        close_conn(m_h);
        m_opened = false;
    }

    char read()
    {
        assert(m_opened);
        return read_conn(m_h);
    }
    void write(char c)
    {
        assert(m_opened);
        write_conn(m_h, c);
    }
    ...

};

“应该是很简单的,可是不知道怎么回事,用了 connection 类的程序总是时不时的崩溃,说是非法的内存操作。”,pisces 显得很苦恼。

zero 一眼就看出了毛病 ——— 这使他小小的自鸣得意了一下 ——— 但是表面上不动声色,等到他看过 pisces 提供的“总是引发崩溃”的代码段之后,他才开口说到:

“这是一个常见的错误 pisces”,zero 尽量使自己的口吻和语气听起来象一个权威,“关于 C++,有一条重要的指导原则:析构函数、拷贝构造函数和赋值运算符三者几乎总是一起出现。也就是说,如果你为一个类写了析构函数,那么往往你不得不再提供一个拷贝构造函数和一个赋值运算符,违反它往往意味着错误。你看这里:”

说着,zero 在屏幕上标出了两行代码:

void some_func()
{
    conn_attr attr;
    ...
    connection c1(512, attr);
    connection tmp = c1;
    ...
}

“这里对象 tmp 是从 c1 拷贝构造而来的,而你没有定义拷贝构造函数,这使得编译器在这里自动进行按位拷贝,而这使得 tmp 和 c1 的所有成员都相等,包括 m_buf 成员。这样在函数返回时,c1 析构的时候 delete 了一遍 m_buf,在 tmp 析构的时候又 delete 了一遍 ……”

“哦!我明白了!” pisces 打断了 zero ,“所以就出现一个非法内存操作,对吧?哎呀,这一条以前在学校里写 string 类的时候遇到过,我怎么会忘了呢?”

“对,你只要写一个拷贝构造函数和一个赋值运算符处理一下 m_buf 指针就可以解决这个问题了。这你自己搞的定吧?”

“我可以的,多谢了 zero !”

zero 心满意足的回到了自己的座位上,开始继续和“你不懂我纤细的心”在 QQ 上探讨“爱情的意义”。可是好景不长,没过多久,本文开头所描述的景象再一次的出现了。

“zero 帮帮忙吧 ~~ ”

zero 在心中叹了口气,抬头问道:“又是什么问题,pisces?”

“呃,还是那个类。我照你说的给 conn 添加了拷贝构造函数,非法内存操作确实少多了,可还是有,还有好像链接传输数据也有点问题 ———— 你还是过来帮我看看吧 ~~”

zero 心不甘情不愿的再次来到了 pisces 的计算机前,翻出 pisces 写的拷贝构造函数检查起来:

connection(const connection& other)
{
    m_attr = other.m_attr;
    m_bufsize = other.m_bufsize;
    m_buf = new char[m_bufsize];
    memcpy(m_buf, other.m_buf, m_bufsize);
    m_opened = other.m_opened;

    m_h = other.m_h;
}

zero 的眉头皱了起来,这个拷贝构造函数似乎应该可以解决问题,显然现在两个 m_buf 各自指向合法的内存,不再存在两次释放的问题。那么问题出在哪儿呢?zero 陷入了沉思。不过仅仅多花了不到 1 分钟,zero 就明白了过来。

“哦!我明白了!见鬼,我怎么会没注意到这个。pisces ,问题还是出在 m_buf 上面,因为链接和缓冲区指针是一一对应的,所以拷贝构造函数里新分配的缓冲区根本不起作用。”

pisces 眨了眨眼,表情略显呆滞。

“给你举个例子吧。”zero 飞快的键入一段测试代码:

connection* pc = NULL;

{
    conn_attr attr;
    connection c1(512, attr);
    c1.open();
    pc = new connection(c1);
}

pc->write('A');

“c1 的构造函数里调用 new 为它的 m_buf 成员分配内存,紧接着在 open 函数里调用 open_conn 打开了一个链接,注意这里我们传入 open_conn 的参数是 c1.m_buf ,所以这个链接对应的缓冲区指针是 c1.m_buf 。然后我们执行 pc = new connection(c1),新对象从 c1 拷贝构造,所以 pc->m_h 和 c1.m_h 相等,也就是说这两个对象保存的 m_h 标识着同一个链接,对应的缓冲区指针都是 c1.m_buf ———— ”

zero 象 Solmyr 常做的那样停了下来,但却失望的看到 pisces 毫无反应,只好接着往下说:

“所以接下来的 pc->write 在调用 write_conn 时候,这个 API 并不知道这是通过另外一个对象在调用它,它仍然试图使用 c1.m_buf 作为缓冲区,但这个时候 c1 已经结束了它的生命周期,c1.m_buf 已经被释放了,所以,这是一个非法的内存访问。”

pisces 舔了舔嘴唇:“ …… 那 …… 那么现在怎么办?”

zero 翻了个白眼 ——— 很明显 pisces 根本没明白是怎么一回事 ——— 开始考虑怎样应付眼前这个问题。

“嗯,看样子,这里必须考虑多个对象共享一个指针的问题,嗯,为了保证这块内存被释放 …… 恐怕 …… 恐怕得用上引用计数技术(请参见“小品文系列之五:垃圾收集”)才搞得定,要不要用 boost::shared_ptr 呢?”,zero 一边想,一边自言自语。突然间 ———

“逻辑的混乱导致实现上的复杂,zero,这个 connection 类千疮百孔啊。”,Solmyr 的声音毫无预兆的在背后响起。

zero 在 0.01 秒内控制住了拔腿飞奔的冲动,以尽可能放松的姿态缓缓的转过身来。在他的面前是披着一贯优雅伪装的 Solmyr,一手端着果汁,一手牢牢的拽着仍在拼命挣扎试图逃走的 pisces 。

“啊 Solmyr ,我正想找你呢,这个问题稍许有点棘手。”

“是吗?那你的腿为什么在抖?”

“嗯?没有,有点冷而已 …… 啊 Solmyr ,你刚刚说什么来着?”

“逻辑的混乱导致实现上的复杂,zero,这个 connection 类千疮百孔。”Solmyr 把 pisces 按在旁边的座位上,接着说到:“你刚才发现的问题只是其中之一而已。看一下这个:”

void some_func()
{
    conn_attr attr;
    ...
    connection c1(512, attr);
    c1.open();
    ...
    connection tmp = c1;
    c1.close();
    tmp.write('a');
    ...
}

“这会导致什么?”

“ …… 试图写入一个已经关闭了链接。”

“还需要我给出多次打开一个链接,多次关闭一个链接,以及各种链接处于打开状态但读写却会引发断言错误的例子吗?”

“ …… 不用了。”

“那你打算怎样修复这些问题?要不要在每个对象里保存一个由它拷贝构造而来的对象列表?或者你打算在文档里写‘以下 371 种方式使用该类会导致无法预知的错误’?”

“ …… ”

Solmyr 重重的叹了口气:“你被 pisces 误导了,zero,因为你只想着怎么帮 pisces 解决问题,如果一开始就让你来设计这个类,情况一定不会这么糟糕。”说着,Solmyr 狠狠的瞪了 pisces 一眼。“不要忘了,C++ 类不是简单的把一堆成员变量和成员函数凑在一起,永远记得这个原则:C++ 中用类来表示概念。”

zero 点了点头。

“我来问你,connection 这个类应该表示什么概念?”

“呃,应该表示‘链接’这个概念。”

“一个 connection 类的对象应该代表 ……”

“应该代表一个实际‘链接’。”

“很好。那么你告诉我,你刚才努力想设计出的那个拷贝构造函数要干什么?”

“ …… 让两个 connection 对象能够表示同一链接。”

“所以 ……”

“ …… 所以 …… 嗯 …… 哦 …… ”zero 露出了恍然大悟的表情:“所以我实际上想做的是要表达这样一个概念:如果一个 connection 对象没有被拷贝,它就表示一个独立的链接,如果它被拷贝了,那么它就和拷贝者表示同一个链接,这也包括拷贝者的拷贝者,拷贝者的拷贝者的拷贝者 …… 天哪,这根本是一团乱麻!”

“对,问题就在这里。一个 connection 对象代表什么?你试图给出一个在逻辑上非常混乱的答案,这导致了实现的复杂性。实际上,如果理清这个逻辑,问题是很简单的:一个 connection 对象代表一个链接,它构造,代表建立了一个链接;它析构,代表这个链接走完了它的生命历程 ——— 这里 open 和 close 这两个成员函数根本就是多余的。至于拷贝构造 ……”

Solmyr 顿了顿,以一种斩钉截铁式的语气说到:

“应该禁止。”

“禁止拷贝?!”

“对,应该禁止。事实上,对于‘链接’这个概念而言,‘拷贝’动作含义模糊:拷贝意味着什么?拷贝构造的对象所表示的链接和原来的链接是什么关系?当使用 connection 类的程序员看到 connection c2 = c1; 这样的代码时,他没法从代码本身看出这是什么意思,他会猜测,c1 和 c2 代表的是一个链接?还是两个链接?只能通过查阅文档来解决,这加重了使用者的负担,而如果禁止拷贝,所有智力正常的程序员都会明白每个 connection 对象唯一的代表一个链接。”

zero 若有所思的点了点头。

“同时,这还能阻止程序员用传值方式向函数传递 connection 对象 ——— 想象一下,如果一个程序员这样使用 connection ,会发生什么?”,Solmyr 键入了下面的代码:

void send_a_greeting(connection c)
{
    c.write("Hello!");
}

zero 没费什么劲就看出了问题:“函数的设计者以为他是在向调用者传入的链接发送消息,但实际上这个函数在按值传递参数的时候创建了一个新链接。”

Solmyr 点了点头,继续说到:“还有,从扩展性的角度考虑,也应该禁止拷贝。比如,假设你将来打算控制链接的创建,把创建过程封装起来,那么这个拷贝构造函数就在你的封装上捅了一个大窟窿 ——— 每个人都可以很方便的利用拷贝构造任意创建链接;又比如,假设将来你需要支持多个类型的链接,要把 connection 作为一个类层次的接口基类,那时,connection 的拷贝构造就必须要禁止,而你之前支持拷贝构造带来的代价就是辛苦的翻遍之前所有的代码去掉所有拷贝构造。”

“那,如果我确实需要在多处访问一个链接,该 ……” zero 没等 Solmyr 回答,自己就接了上去,“呃,也很简单,只要传递引用就可以了,或者如果需要更好的控制,可以用智能指针什么的。”

“完全正确。说起来,其实许多类 ——— 比许多人所认为的要多的多 ——— 所表示的概念对于‘拷贝’这个动作都没有清楚的定义,比如常见的‘窗口’、‘文件’、‘事务’等等等等,禁止它们拷贝往往可以让代码的逻辑清楚许多。以后你在设计类的时候,完全可以首先考虑是否禁止它的拷贝构造,如果不能禁止,再去考虑怎么写拷贝构造函数的问题。好了 zero ,现在你能给出 connection 的实现吗?”

“Sure!只要将拷贝构造函数和重载赋值运算符设为私有,就可以禁止拷贝了。”zero 拖过键盘,三两下屏幕上就出现了一个新的实现:

class connection
{
private:
    conn_attr m_attr;
    int m_bufsize;
    char* m_buf;

    conn_handle m_h;
    ...

public:
    connection(const conn_attr& attr, int bufsize)
    : m_attr(attr), m_bufsize(bufsize)
    {
        m_buf = new char[m_bufsize];
        m_h = open_conn(&m_attr, m_buf, m_bufsize);
    }
    ~connection()
    {
        close_conn(m_h);
        delete m_buf;
    }

    void write(char c){ write_conn(m_h, c); }
    char read(){ return read_conn(m_h); }
    ...

private:
    connection(const connection&);
    connection& operator=(const connection&);
};

“嗯,很好,这个问题可以告一段落了。”Solmyr 点了点头,准备离开,但又停了下来:“对了 zero ,pisces 他们这边曾经打报告要求增加人手,从今天的情况来看也确实需要有个懂点 C++ 的人加强这边。我看你正好有空,这个事就你来负责吧。”

zero 心中暗暗叫苦,赶紧分辨:“没有啊 Solmyr,我现在手边的事情多得做不完啊!”

“是吗?哦 …… 对了,我刚才接到网管的报告,说有个人的电脑最近频繁的访问 QQ 的服务器,那个人是谁来着?”,Solmyr 又露出了他招牌式的微笑。

“呃 …… 我又想了想,虽然我确实事情比较多,但团队合作精神还是要发扬的。”

“嗯,这样就好。” Solmyr 心满意足的离开了。

“真见鬼!”确认 Solmyr 走远后,zero 才把在心里憋着的抱怨吐了出来:“好不容易有一段可以休息休息的空档,这下子又泡汤了!真该死。”正在 zero 忿忿不平的时候,一个幽幽的声音从旁边飘了过来:

“zero,刚才你和 Solmyr 讲的什么‘概念’、‘禁止拷贝’、‘类层次’…… 这些都是什么呀?还是你给我讲讲吧 ~~”

zero 转过头,看到 pisces 又在以非常“诚恳”的眼神看着他,再想到自己今后的任务,突然间觉得脑袋隐隐的痛了起来 ——— 他似乎有一点明白了,为什么没事的时候 Solmyr 总在揉自己的太阳穴 ……

本文地址:http://com.8s8s.com/it/it29497.htm