C++ 工程实践(8):值语义

标签: 工程 实践 语义 | 发表时间:2011-08-16 21:14 | 作者:陈硕 ndv
出处:http://www.cnblogs.com/Solstice/

陈硕 (giantchen_AT_gmail)
http://blog.csdn.net/Solstice  http://weibo.com/giantchen
陈硕关于 C++ 工程实践的系列文章: http://blog.csdn.net/Solstice/category/802325.aspx
排版正常的版本: http://www.cnblogs.com/Solstice/category/287661.html
陈硕博客文章合集下载: http://blog.csdn.net/Solstice/archive/2011/02/24/6206154.aspx
本作品采用“Creative Commons 署名-非商业性使用-禁止演绎 3.0 Unported 许可协议(cc by-nc-nd)”进行许可。http://creativecommons.org/licenses/by-nc-nd/3.0/

本文是前一篇《C++ 工程实践(7):iostream 的用途与局限》的后续,在这篇文章的“iostream 与标准库其他组件的交互”一节,我简单地提到iostream的对象和C++标准库中的其他对象(主要是容器和string)具有不同的语义,主要体现在iostream不能拷贝或赋值。今天全面谈一谈我对这个问题的理解。

本文的“对象”定义较为宽泛,a region of memory that has a type,在这个定义下,int、double、bool 变量都是对象。

什么是值语义

值语义(value sematics)指的是对象的拷贝与原对象无关,就像拷贝 int 一样。C++ 的内置类型(bool/int/double/char)都是值语义,标准库里的 complex<> 、pair<>、vector<>、map<>、string 等等类型也都是值语意,拷贝之后就与原对象脱离关系。Java 语言的 primitive types 也是值语义。

与值语义对应的是“对象语义/object sematics”,或者叫做引用语义(reference sematics),由于“引用”一词在 C++ 里有特殊含义,所以我在本文中使用“对象语义”这个术语。对象语义指的是面向对象意义下的对象,对象拷贝是禁止的。例如 muduo 里的 Thread 是对象语义,拷贝 Thread 是无意义的,也是被禁止的:因为 Thread 代表线程,拷贝一个 Thread 对象并不能让系统增加一个一模一样的线程。

同样的道理,拷贝一个 Employee 对象是没有意义的,一个雇员不会变成两个雇员,他也不会领两份薪水。拷贝 TcpConnection 对象也没有意义,系统里边只有一个 TCP 连接,拷贝 TcpConnection  对象不会让我们拥有两个连接。Printer 也是不能拷贝的,系统只连接了一个打印机,拷贝 Printer 并不能凭空增加打印机。凡此总总,面向对象意义下的“对象”是 non-copyable。

Java 里边的 class 对象都是对象语义/引用语义。ArrayList<Integer> a = new ArrayList<Integer>(); ArrayList<Integer> b = a; 那么 a 和 b 指向的是同一个ArrayList 对象,修改 a 同时也会影响 b。

值语义与 immutable 无关。Java 有 value object 一说,按(PoEAA 486)的定义,它实际上是 immutable object,例如 String、Integer、BigInteger、joda.time.DateTime 等等(因为 Java 没有办法实现真正的值语义 class,只好用 immutable object 来模拟)。尽管 immutable object 有其自身的用处,但不是本文的主题。muduo 中的 Date、Timestamp 也都是 immutable 的。

C++中的值语义对象也可以是 mutable,比如 complex<>、pair<>、vector<>、map<>、string 都是可以修改的。muduo 的 InetAddress 和 Buffer 都具有值语义,它们都是可以修改的。

值语义的对象不一定是 POD,例如 string 就不是 POD,但它是值语义的。

值语义的对象不一定小,例如 vector<int> 的元素可多可少,但它始终是值语义的。当然,很多值语义的对象都是小的,例如complex<>、muduo::Date、muduo::Timestamp。

值语义与生命期

值语义的一个巨大好处是生命期管理很简单,就跟 int 一样——你不需要操心 int 的生命期。值语义的对象要么是 stack object,或者直接作为其他 object 的成员,因此我们不用担心它的生命期(一个函数使用自己stack上的对象,一个成员函数使用自己的数据成员对象)。相反,对象语义的 object 由于不能拷贝,我们只能通过指针或引用来使用它。

一旦使用指针和引用来操作对象,那么就要担心所指的对象是否已被释放,这一度是 C++ 程序 bug 的一大来源。此外,由于 C++ 只能通过指针或引用来获得多态性,那么在C++里从事基于继承和多态的面向对象编程有其本质的困难——资源管理。

考虑一个简单的对象建模——家长与子女:a Parent has a Child, a Child knows his/her Parent。在 Java 里边很好写,不用担心内存泄漏,也不用担心空悬指针:

public class Parent
{
    private Child myChild;
}
    
public class Child
{
    private Parent myParent;
}

只要正确初始化 myChild 和 myParent,那么 Java 程序员就不用担心出现访问错误。一个 handle 是否有效,只需要判断其是否 non null。

在 C++ 里边就要为资源管理费一番脑筋:Parent 和 Child 都代表的是真人,肯定是不能拷贝的,因此具有对象语义。Parent 是直接持有 Child 吗?抑或 Parent 和 Child 通过指针互指?Child 的生命期由 Parent 控制吗?如果还有 ParentClub 和 School 两个 class,分别代表家长俱乐部和学校:ParentClub has many Parent(s),School has many Child(ren),那么如何保证它们始终持有有效的 Parent 对象和 Child 对象?何时才能安全地释放 Parent 和 Child ?

直接但是易错的写法:

class Child;

class Parent : boost::noncopyable
{
 private:
  Child* myChild;
};

class Child : boost::noncopyable
{
 private:
  Parent* myParent;
};

如果直接使用指针作为成员,那么如何确保指针的有效性?如何防止出现空悬指针?Child 和 Parent 由谁负责释放?在释放某个 Parent 对象的时候,如何确保程序中没有指向它的指针?在释放某个 Child 对象的时候,如何确保程序中没有指向它的指针?

这一系列问题一度是C++面向对象编程头疼的问题,不过现在有了 smart pointer,我们可以借助 smart pointer 把对象语义转换为值语义,从而轻松解决对象生命期:让 Parent 持有 Child 的 smart pointer,同时让 Child 持有 Parent 的 smart pointer,这样始终引用对方的时候就不用担心出现空悬指针。当然,其中一个 smart pointer 应该是 weak reference,否则会出现循环引用,导致内存泄漏。到底哪一个是 weak reference,则取决于具体应用场景。

如果 Parent 拥有 Child,Child 的生命期由其 Parent 控制,Child 的生命期小于 Parent,那么代码就比较简单:

class Parent;
class Child : boost::noncopyable
{
 public:
  explicit Child(Parent* myParent_)
    : myParent(myParent_)
  {
  }

 private:
  Parent* myParent;
};

class Parent : boost::noncopyable
{
 public:
  Parent()
    : myChild(new Child(this))
  {
  }

 private:
  boost::scoped_ptr<Child> myChild;
};

在上面这个设计中,Child 的指针不能泄露给外界,否则仍然有可能出现空悬指针。

如果 Parent 与 Child 的生命期相互独立,就要麻烦一些:

class Parent;
typedef boost::shared_ptr<Parent> ParentPtr;

class Child : boost::noncopyable
{
 public:
  explicit Child(const ParentPtr& myParent_)
    : myParent(myParent_)
  {
  }

 private:
  boost::weak_ptr<Parent> myParent;
};
typedef boost::shared_ptr<Child> ChildPtr;


class Parent : public boost::enable_shared_from_this<Parent>,
               private boost::noncopyable
{
 public:
  Parent()
  {
  }

  void addChild()
  {
    myChild.reset(new Child(shared_from_this()));
  }

 private:
  ChildPtr myChild;
};

int main()
{
  ParentPtr p(new Parent);
  p->addChild();
}

上面这个 shared_ptr+weak_ptr 的做法似乎有点小题大做。

考虑一个稍微复杂一点的对象模型:a Child has parents: mom and dad; a Parent has one or more Child(ren); a Parent knows his/her spouser. 这个对象模型用 Java 表述一点都不复杂,垃圾收集会帮我们搞定对象生命期。

public class Parent
{
    private Parent mySpouser;
    private ArrayList<Child> myChildren;
}

public class Child
{
    private Parent myMom;
    private Parent myDad;
}

如果用 C++ 来实现,如何才能避免出现空悬指针,同时避免出现内存泄漏呢?借助 shared_ptr 把裸指针转换为值语义,我们就不用担心这两个问题了:

class Parent;
typedef boost::shared_ptr<Parent> ParentPtr;

class Child : boost::noncopyable
{
 public:
  explicit Child(const ParentPtr& myMom_,
                 const ParentPtr& myDad_)
    : myMom(myMom_),
      myDad(myDad_)
  {
  }

 private:
  boost::weak_ptr<Parent> myMom;
  boost::weak_ptr<Parent> myDad;
};
typedef boost::shared_ptr<Child> ChildPtr;

class Parent : boost::noncopyable
{
 public:
  Parent()
  {
  }

  void setSpouser(const ParentPtr& spouser)
  {
    mySpouser = spouser;
  }

  void addChild(const ChildPtr& child)
  {
    myChildren.push_back(child);
  }

 private:
  boost::weak_ptr<Parent> mySpouser;
  std::vector<ChildPtr> myChildren;
};

int main()
{
  ParentPtr mom(new Parent);
  ParentPtr dad(new Parent);
  mom->setSpouser(dad);
  dad->setSpouser(mom);
  {
    ChildPtr child(new Child(mom, dad));
    mom->addChild(child);
    dad->addChild(child);
  }
  {
    ChildPtr child(new Child(mom, dad));
    mom->addChild(child);
    dad->addChild(child);
  }
}

如果不使用 smart pointer,用 C++ 做面向对象编程将会困难重重。

值语义与标准库

C++ 要求凡是能放入标准容器的类型必须具有值语义。准确地说:type 必须是 SGIAssignable concept 的 model。但是,由 于C++ 编译器会为 class 默认提供 copy constructor 和 assignment operator,因此除非明确禁止,否则 class 总是可以作为标准库的元素类型——尽管程序可以编译通过,但是隐藏了资源管理方面的 bug。

因此,在写一个 class 的时候,先让它继承 boost::noncopyable,几乎总是正确的。

在现代 C++ 中,一般不需要自己编写 copy constructor 或 assignment operator,因为只要每个数据成员都具有值语义的话,编译器自动生成的 member-wise copying&assigning 就能正常工作;如果以 smart ptr 为成员来持有其他对象,那么就能自动启用或禁用 copying&assigning。例外:编写 HashMap 这类底层库时还是需要自己实现 copy control。

值语义与C++语言

C++ 的 class 本质上是值语义的,这才会出现 object slicing 这种语言独有的问题,也才会需要程序员注意 pass-by-value 和 pass-by-const-reference 的取舍。在其他面向对象编程语言中,这都不需要费脑筋。

值语义是C++语言的三大约束之一,C++ 的设计初衷是让用户定义的类型(class)能像内置类型(int)一样工作,具有同等的地位。为此C++做了以下设计(妥协):

  • class 的 layout 与 C struct 一样,没有额外的开销。定义一个“只包含一个 int 成员的 class ”的对象开销和定义一个 int 一样。
  • 甚至 class data member 都默认是 uninitialized,因为函数局部的 int 是 uninitialized。
  • class 可以在 stack 上创建,也可以在 heap 上创建。因为 int 可以是 stack variable。
  • class 的数组就是一个个 class 对象挨着,没有额外的 indirection。因为 int 数组就是这样。
  • 编译器会为 class 默认生成 copy constructor 和 assignment operator。其他语言没有 copy constructor 一说,也不允许重载 assignment operator。C++ 的对象默认是可以拷贝的,这是一个尴尬的特性。
  • 当 class type 传入函数时,默认是 make a copy (除非参数声明为 reference)。因为把 int 传入函数时是 make a copy。
  • 当函数返回一个 class type 时,只能通过 make a copy(C++ 不得不定义 RVO 来解决性能问题)。因为函数返回 int 时是 make a copy。
  • 以 class type 为成员时,数据成员是嵌入的。例如 pair<complex<double>, size_t> 的 layout 就是 complex<double> 挨着 size_t。

这些设计带来了性能上的好处,原因是 memory locality。比方说我们在 C++ 里定义 complex<double> class,array of complex<double>, vector<complex<double> >,它们的 layout 分别是:(re 和 im 分别是复数的实部和虚部。)

value1

而如果我们在 Java 里干同样的事情,layout 大不一样,memory locality 也差很多:

value2

Java 里边每个 object 都有 header,至少有两个 word 的开销。对比 Java 和 C++,可见 C++ 的对象模型要紧凑得多。

待续

下一篇文章我会谈与值语义紧密相关的数据抽象(data abstraction),解释为什么它是与面向对象并列的一种编程范式,为什么支持面向对象的编程语言不一定支持数据抽象。C++在最初的时候是以 data abstraction 为卖点,不过随着时间的流逝,现在似乎很多人只知 Object-Oriented,不知 data abstraction 了。C++ 的强大之处在于“抽象”不以性能损失为代价,下一篇文章我们将看到具体例子。

作者: 陈硕 发表于 2011-08-16 21:14 原文链接

评论: 2 查看评论 发表评论


最新新闻:
· 传苹果向夏普投资10亿美元确保iPhone屏幕产能(2011-08-17 13:39)
· 移动互联网中的中国运营商(2011-08-17 13:38)
· Google 收购 MOTO,小米重做手机,都为社交而来。(2011-08-17 13:33)
· 智能手机冷战和你(2011-08-17 13:29)
· 百度遭央视连续曝光 多家机构发布评级报告(2011-08-17 13:25)

编辑推荐:程序员职业发展的绊脚石-思想的枷锁

网站导航:博客园首页  我的园子  新闻  闪存  小组  博问  知识库

相关 [工程 实践 语义] 推荐:

C++ 工程实践(8):值语义

- ndv - 博客园-陈硕的 Blog
陈硕 (giantchen_AT_gmail). 陈硕关于 C++ 工程实践的系列文章: http://blog.csdn.net/Solstice/category/802325.aspx. 排版正常的版本: http://www.cnblogs.com/Solstice/category/287661.html.

文字语义纠错技术探索与实践-张健

- - 我爱自然语言处理
文本语义纠错的使用场景非常广泛,基本上只要涉及到写作就有文本纠错的需求. 书籍面市前就有独立的校对的环节来保障出版之后不出现明显的问题. 在新闻中我们也时不时看到因为文字审核没到位造成大乌龙的情况,包括上市公司在公开文书上把“临时大会”写成为“临死大会”,政府文件把“报效国家”写成了“报销国家”. 有关文本纠错的辅助工具能给文字工作人员带来较大的便利,对审核方面的风险也大幅降低.

C++ 工程实践(9):数据抽象

- roc - 博客园-陈硕的 Blog
陈硕 (giantchen_AT_gmail). 陈硕关于 C++ 工程实践的系列文章: http://blog.csdn.net/Solstice/category/802325.aspx. 排版正常的版本: http://www.cnblogs.com/Solstice/category/287661.html.

混沌工程介绍与实践 | ChaosBlade

- -
在分布式系统架构下,服务间的依赖日益复杂,很难评估单个服务故障对整个系统的影响,并且请求链路长,监控告警的不完善导致发现问题、定位问题难度增大,同时业务和技术迭代快,如何持续保障系统的稳定性和高可用性受到很大的挑战. 我们知道发生故障的那一刻不是由你来选择的,而是那一刻来选择你,你能做的就是为之做好准备.

C++ 工程实践(7):iostream 的用途与局限

- tangsty - 博客园-陈硕的 Blog
陈硕 (giantchen_AT_gmail). 陈硕关于 C++ 工程实践的系列文章: http://blog.csdn.net/Solstice/category/802325.aspx. 陈硕博客文章合集下载: http://blog.csdn.net/Solstice/archive/2011/02/24/6206154.aspx.

C++ 工程实践(4):二进制兼容性

- 山石 - C++博客-首页原创精华区
陈硕 (giantchen_AT_gmail). 本文主要讨论 Linux x86/x86-64 平台,偶尔会举 Windows 作为反面教材. C/C++ 的二进制兼容性 (binary compatibility) 有多重含义,本文主要在“头文件和库文件分别升级,可执行文件是否受影响”这个意义下讨论,我称之为 library (主要是 shared library,即动态链接库)的 ABI (application binary interface).

C++ 工程实践(7):iostream 的用途与局限

- marmot694745 - C++博客-首页原创精华区
陈硕 (giantchen_AT_gmail). 陈硕关于 C++ 工程实践的系列文章: http://blog.csdn.net/Solstice/category/802325.aspx. 陈硕博客文章合集下载: http://blog.csdn.net/Solstice/archive/2011/02/24/6206154.aspx.

Google 发布关于机器学习工程的最佳实践

- -
本文档旨在帮助已掌握机器学习基础知识的人员从 Google 机器学习的最佳实践中受益. 它介绍了一种机器学习样式,类似于 Google C++ 样式指南和其他常用的实用编程指南. 如果您学习过机器学习方面的课程,或者拥有机器学习模型的构建或开发经验,则具备阅读本文档所必需的背景知识. 在我们讨论有效的机器学习的过程中,会反复提到下列术语:.