Hello World
Spiga

再谈public类型中internal成员的坏味道

2009-08-27 10:49 by 老赵, 4924 visits

上一篇文章里我讨论了一个类中internal成员可能会造成的坏味道,并且认为如果您的类型中出现了这个情况,可能就值得检查一下设计上是不是有问题了。文章中我提出了三种可能出现internal的情况,其中两种争议不大,不过对于“public类中是否应该出现internal成员”这一点似乎引起了一些争议。从评论中发现,讨论的一部分焦点并不是我的本意,这可能是我前文描述地较为简单而造成的,因此我现在对于这个方面再进行略为详细的探讨。

首先可能还是需要强调的是,我并没有说不该用internal关键字,有些朋友提出,internal关键字可以控制成员的访问级别,可以把一些非标准的类型(如unsigned int)控制在内部。这些都对,但它们不是我谈论的目标。我讨论的不是internal关键字是否有用(这不值得讨论,怎么可能没用),而是“在类中的internal成员”是否为一种合适的设计。这涉及类的职责,语义,类之间的协作等话题,并不是在讨论简单的“访问级别”控制。

在前文中,我用简单的代码片断来说明“public类中的internal成员可能是一个坏味道”,这次我打算使用更详细的代码来说明问题。请看这样的类型:

internal class ProductDetail { }

public class Product
{
    public int ProductID { get; set; }

    public string Name { get; set; }

    internal XElement GetXmlData()
    {
        return new XElement("Product",
            new XElement("ProductID", this.ProductID),
            new XElement("Name", this.Name),
            new XElement("Detail", ...)); // internal detail
    }

    private ProductDetail m_internalDetail;
}

您的项目中有一个Product类,其中有一些公开的成员,对外释放了Product对象的ID,Name以及一些公开的行为。不过在项目“内部”还有一个需求,是将一个Product转化为XML进行保存或传输。这个功能只对内部有作用,因此Product类中还有一个internal方法称为是GetXmlData,返回一个表示自身的XElement对象。其中会包含它的一些公开信息,以及只有Product类型“自己”才知道的私有信息,这里我们把它称为是ProductDetail。

现在,我们可以这样调用GetXmlData方法:

Product product = new Product();
XElement xml = product.GetXmlData();

现在,GetXmlData方式是internal的,因为它只对项目内部有作用,这也是internal关键字的作用,控制访问级别嘛。似乎这个设计没有什么问题,但是请思考一下,我之前为什么说公开类的internal成员可能是一个坏味道呢?

其实就是在“职责”上。因为这个对象既有public成员,又有internal成员,这意味着它有一部分功能是分开的,一部分功能是对内的,这在某些时候就可能会意味着这个对象承担了两种“职责”。就如Product对象,将自己的信息生成为XML是Product对象的职责吗?在您的环境中答案可能为“是”,不过在这里就认为不太妥当吧。Product对象知道自己有哪些信息,但是它按理来说,不应该负责XML的生成,不应该负责XML的格式、元素名、命名空间等XML特有的属性。有关XML生成的逻辑应该不属于Product类,这应该是其他类型的职责。

于是,我们对上面的代码进行重构:

internal class ProductDetail { }

public class Product
{
    public int ProductID { get; set; }

    public string Name { get; set; }

    private ProductDetail m_internalDetail;

    internal ProductDetail Detail
    {
        get
        {
            return this.m_internalDetail;
        }
    }
}

internal class ProductXmlGenerator
{
    public XElement GetXmlData(Product product)
    {
        return new XElement("Product",
            new XElement("ProductID", product.ProductID),
            new XElement("Name", product.Name),
            new XElement("Detail", ...)); // internal detail
    }
}

现在使用的代码便修改为:

Product product = new Product();
ProductXmlGenerator xmlGenderator = new ProductXmlGenerator();
XElement xml = xmlGenderator.GetXmlData(product);

至此,XML生成所需要的逻辑便转移到ProductXmlGenerator类中,需要获得XML数据的时候,便实例化一个ProductXmlGenerator,将一个Product对象转化为XML。不过,由于此时Product对象的数据需要被其他类访问到了,我们又必须创建一个internal的Detail属性,将原本私有的ProductDetail字段暴露给Product之外的对象。

不过,目前的做法还是有一些问题。虽然生成XML的逻辑被分离的出去了,但是另一部分原本应该属于Product的职责也被转移了。一般来说,只有Product自己才知道“有哪些数据需要被保存”,它不知道的只是“应该如何保存这些数据”,而后者才是我们需要分离出去的逻辑。但是ProductXmlGenerator同样包含了本不该属于自己的职责,它也去关心Product对象的细节了。这也是为什么我们需要把原本是私密的ProductDetail也通过internal的方式释放出去。

其实在面向对象设计领域,这也是一个有名的“准则”,那就是“Tell, don’t ask”。现在的做法便破坏了这个准则。

因此,再次重构:

internal interface IDataCollector
{
    void CollectInt32(string name, int value);
    void CollectString(string name, string value);
}

internal interface IDataCollectable
{
    void Collect(IDataCollector collector);
}

internal class XmlDataCollector : IDataCollector
{
    void IDataCollector.CollectInt32(string name, int value) { }
    void IDataCollector.CollectString(string name, string value) { }

    public XElement Result { get { … } }
}

internal class ProductDetail { }

public class Product : IDataCollectable
{
    public int ProductID { get; set; }

    public string Name { get; set; }

    private ProductDetail m_internalDetail;

    void IDataCollectable.Collect(IDataCollector collector)
    {
        collector.CollectInt32("ProductID", this.ProductID);
        collector.CollectString("Name", this.Name);
        // collect the details
    }
}

使用方式如下:

IDataCollectable product = new Product();
XmlDataCollector collector = new XmlDataCollector();
product.Collect(collector);
XElement element = collector.Result;

至此,我们提取出ICollectable和ICollector两个接口,让Product关心自己有哪些数据应该被“收集”,而XmlDataCollector则负责将收集的数据转化为合适的XML。大家完全通过抽象进行交互,各司其职。如果需要的话,系统中也可以出现多种收集器(如JsonDataCollector,BinaryDataCollector),同样可以出现多种可收集的对象。

您可能注意到了,无论是Product还是XmlDataCollector都是显示实现internal接口的。这便是前文评论中Ivony...同学所说的“被很多人忽视”的做法。也是因为如此,我们的代码中使用IDataCollectable对象来引用product对象。如果您想直接在Product对象上调用Collect方法,那就必须加上一个internal的Collect方法了

嗯?这不是又出现internal成员了吗?没错,不过我从来没有像说明“internal成员是一定不能使用的”,我强调的只是一种“倾向性”,一种“职责不明”的倾向性。如果你确定这个internal成员的职责没有任何问题,而且肯定是必要的,那就这样使用吧。我们不是为了去除internal而去除internal,否则和为了设计而设计,为了敏捷而敏捷有什么区别呢?

哦,对了,最后一提,其实我们最终的做法,和.NET框架中ISerializable还是颇为相像的。

Creative Commons License

本文基于署名 2.5 中国大陆许可协议发布,欢迎转载,演绎或用于商业目的,但是必须保留本文的署名赵劼(包含链接),具体操作方式可参考此处。如您有任何疑问或者授权方面的协商,请给我留言

Add your comment

26 条回复

  1. 徐少侠
    *.*.*.*
    链接

    徐少侠 2009-08-27 10:58:00


    是这个意思
    职责明确

  2. chy710
    *.*.*.*
    链接

    chy710 2009-08-27 11:07:00

    弹指一挥间,一谈两博文.

    老赵最近专职写博客了?高速!

  3. progame
    *.*.*.*
    链接

    progame 2009-08-27 11:09:00

    在这里 分离数据和行为的做法是正确的
    但这个并不是可以否定public 类中包含internal方法的理由

  4. 横刀天笑
    *.*.*.*
    链接

    横刀天笑 2009-08-27 11:10:00

    基本明白你的意思,使用了inernal很多时候是因为“多重职责”的问题。不过就你刚才举得这个例子,我倒有一些别的想法。
    如果我们在这个第一个重构的时候,将ProductXmlGenerator类作为Product的一个nested class
    也许就不会造成
    《《
    只有Product自己才知道“有哪些数据需要被保存”,它不知道的只是“应该如何保存这些数据”,而后者才是我们需要分离出去的逻辑
    》》
    这个问题,也就不会引起后面那个“更复杂”的重构

  5. 钧梓昊逑
    *.*.*.*
    链接

    钧梓昊逑 2009-08-27 11:22:00

    “立志打造国内最好的.NET技术博客”

  6. 老赵
    admin
    链接

    老赵 2009-08-27 11:22:00

    progame:
    在这里 分离数据和行为的做法是正确的
    但这个并不是可以否定public 类中包含internal方法的理由


    不要“非左即右”,文章里也已经强调过多次了,我从来没有想要“否定”什么东西。

  7. 老赵
    admin
    链接

    老赵 2009-08-27 11:23:00

    @横刀天笑
    我觉得最好不要用内部类,因为这样还是有较多依赖的,而且把不该暴露的也暴露了。
    Kent Beck在《实现模式》中也提到了这一点。

    最好还是基于抽象进行开发,易于扩展也易于单元测试。
    当然也是个“度”的问题,我觉得要遵循的其实也就是SOLID原则,不违反这个原则就可以了。

  8. 老赵
    admin
    链接

    老赵 2009-08-27 11:24:00

    @chy710
    有什么想法就写出来而已,这篇也就1小时。

  9. 老赵
    admin
    链接

    老赵 2009-08-27 11:29:00

    钧梓昊逑:“立志打造国内最好的.NET技术博客”


    嗯,我专注于博客。

  10. 边城浪1[未注册用户]
    *.*.*.*
    链接

    边城浪1[未注册用户] 2009-08-27 12:16:00

    .NET 框架里面的internal类好像还挺多的.
    public类型中internal成员..
    这个还没注意过..

  11. craboYang
    *.*.*.*
    链接

    craboYang 2009-08-27 12:55:00

    看到第一次重构是ProductXmlGenerator时,就明白[Serializable]该引入了, 不知你整IDataCollectable
    有合意义。 ProductXmlGenerator+[Serializable] 不就可以?

  12. 中华小鹰
    *.*.*.*
    链接

    中华小鹰 2009-08-27 13:32:00

    说实在的,对于product.Collect(collector);这样的语义,以及如此多的接口,我觉得还不如刚开始的设计。语义更清晰,可读性更高。虽然说也确实存在一些坏味道。

  13. 王德水
    *.*.*.*
    链接

    王德水 2009-08-27 13:33:00

    喜欢看这样的,一步步的引出来

  14. 老赵
    admin
    链接

    老赵 2009-08-27 14:13:00

    craboYang:
    看到第一次重构是ProductXmlGenerator时,就明白[Serializable]该引入了, 不知你整IDataCollectable
    有合意义。 ProductXmlGenerator+[Serializable] 不就可以?


    首先,这是示例而已,只是和ISerialzable形式上有些接近。实际上,是不是直接用Serializable是具体情况具体分析的事情。
    其次,后面不是还有XmlDataCollector,JsonDataCollector,BinaryDataCollector等东西吗?

  15. 老赵
    admin
    链接

    老赵 2009-08-27 14:19:00

    @中华小鹰
    对于简单的示例,你可能觉得类有些多,每个类都很简单,交互复杂些。你会觉得“本来就很简单的东西”。
    但是到了复杂的情况下,就是需要拆分职责了。一般来说,都是把类型拆分地细至一些进行组合和交互。
    其实就是SOLID的S,单一职责,一个类如果职责单一,一般都不会很多公开成员。
    即使是很多公开成员,内部也是分摊给多个内部类协作的。

  16. Teddy's Knowledge Bas…
    *.*.*.*
    链接

    Teddy's Knowledge Base 2009-08-27 14:43:00

    @Jeffrey Zhao
    我也写过不少internal的类和成员的情况,谈谈自己的理解。

    单就你的第一个例子代码中的GetXmlData方法来说,我的第一感觉,相对于一个internal方法,我可能会把它放到一个internal的extention method里面,如果方法的实现不需要访问类的非public成员的话。当然,如果从职责的角度判断他不应该属于这个类,并且潜在的可能会造成使用者或后续维护的困惑的话,把它重构到另一个类,使得职责更清晰自然也没问题,但也要综合看使用、维护成本是不是值得。单就你最后重构的结果来说,也许职责稍微清晰了些,但是,多了那么多接口或类是否值得,我看值得商榷,感觉上多少有点过度设计的嫌疑。

    另外你说到“一部分暴露给外部,一部分暴露给内部是两种职责”我并不完全认同。我的理解是,internal和public的区别更大程度上不是职责的区别。从职责的角度来说,internal近似public,在assembly内部,internal和public从可访问性的角度来讲没有区别。

    那么何时应该用public,而不是internal呢?一般来讲,我暴露组件和组件的成员时一般原则是应该尽可能的少暴露,只暴露真正必要的。所有非public的成员将来就都还有任意修改的余地,这是第一考虑的好处。如果太关注职责来隔离internal和public成员,那么,当以后需要暴露internal的成员为public时,是不是又可能会手忙脚乱的再重构一回呢?

  17. 老赵
    admin
    链接

    老赵 2009-08-27 14:59:00

    @Teddy's Knowledge Base
    我这个“命题”的确不太常见,意料中会引起争议,因此我特地把“前提”强调了很多,其实就是类似于重构方式的“前置条件”……
    如果脱离“前置条件”,只是在讨论“internal是坏味道”这个命题就没有意义了,因为它肯定是假命题的,没有绝对的东西。

    对于public和internal,我肯定知道是访问级别上的区别,呵呵。我说“一部分对外,一部分对内”所以职责不清,都是在说“可能”,而不是“一定”啊。 // 现在我已经把它标红了。
    还有,internal和public对于assembly来说都是一样的,所以我现在讨论的都是“在public类中”的internal成员,标题就写着了。

    最后就是,重构与否,职责划分的细致程度,是否“过渡设计”肯定是要权衡的,Refactoring中的重构方式,也不可能是通用的,不是吗?
    否则Replace Delegation with Inheritance和Replace Inheritance with Delegation怎么会同时出现,呵呵。

    我的意思其实就是,如果出现了internal方法,那么需要留意一下职责是不是清晰,如果清晰,自然没事。如果不清晰,那么就重构。

  18. craboYang
    *.*.*.*
    链接

    craboYang 2009-08-27 21:24:00

    Jeffrey Zhao:
    后面不是还有XmlDataCollector,JsonDataCollector,BinaryDataCollector等东西吗?


    只要有[Serializable], 要用哪个Collector有什么区别? WCF binding可以是Binary、WS、SOAP,但并不因此而需要改变Attribute. 我的意思应该是: 既然类似Serializable,为啥要再发明新的轮子。

  19. OOLi
    *.*.*.*
    链接

    OOLi 2009-08-27 21:25:00

    信老赵,得永生!

    看了好长时间,看出点区别:

    先是在ProductXmlGenerator里引用Product,结果ProductXmlGenerator无权知道Product的detail,无奈就把detail变成internal了

    后来加了接口,Product引用了一个IDataCollector的接口,这个IDataCollector负责收集数据,这样就不用把detail变成internal了,改成private的了,外界也看不到了。

  20. 老赵
    admin
    链接

    老赵 2009-08-27 21:35:00

    craboYang:
    只要有[Serializable], 要用哪个Collector有什么区别? WCF binding可以是Binary、WS、SOAP,但并不因此而需要改变Attribute. 我的意思应该是: 既然类似Serializable,为啥要再发明新的轮子。


    这里发明新轮子的目的,从最开始就是为了“一步一步说明问题”。我比较笨,一时没有想到更合适的示例,不过我想您用不着抓住这点不放吧。
    还有便是,我记得WCF只是使用了SerializableAttribute标记,然后直接去读对象中的字段,那么它会不会利用ISerializable接口?
    我这里相当于实现了一个更高级的ISerializable接口,如果WCF也已经有这个功能的话,那么的确我这个就纯粹是个演示了。

  21. JesseQu
    *.*.*.*
    链接

    JesseQu 2009-08-27 23:59:00

    从另外的一个角度来看,internal是我们在代码演进过程中平衡职责粒度、标记代码的结构设计活跃度的一个有力工具。

  22. 老赵
    admin
    链接

    老赵 2009-08-28 00:20:00

    @JesseQu
    想了一下,你说的这个似乎蛮有道理的啊。

  23. yzlhccdec
    *.*.*.*
    链接

    yzlhccdec 2009-08-28 13:13:00

    我不是很理解为什么GetXmlData()不应该是Product的职责?这东西跟ToString()不是一个意思么?难道ToString()也有必要包装一次?

  24. 老赵
    admin
    链接

    老赵 2009-08-28 13:30:00

    @yzlhccdec
    所以有人认为,ToString()和GetHashCode()不应该定义在Object上。
    不过这里就当是个“假设”吧,不是GetXmlData也可以有其他Product职责外的方法的。

  25. vczh[未注册用户]
    *.*.*.*
    链接

    vczh[未注册用户] 2009-08-28 17:20:00

    其实可以等价的理解成

    设计了一种格式叫做DataCollector
    然后所有的类都去生成DataCollector
    然后用一个类把DataCollector变成XML

    现在我用XML去实现XMLDataCollector然后传给你的类,实际上我就有了一个XML了。所以问题就是

    既然XML和DataCollector的可扩展性都是一样的,那么你把XML语言换成了DataCollector语言,有什么区别呢?举个例子,我将来可能要生成json,那么是XMLDataCollector和JSONDataCollector好呢,还是就生成XML然后再来一个XML2JSONConverter好呢?

  26. 老赵
    admin
    链接

    老赵 2009-08-28 17:40:00

    @vczh
    我认为Xml2Json不是个好的设计,因为XML本身就是一个外部的协议,不是程序内部的通信方式,这一步是多余的。

发表回复

登录 / 登录并记住我 ,登陆后便可删除或修改已发表的评论 (请注意保留评论内容)

昵称:(必填)

邮箱:(必填,仅用于Gavatar

主页:(可选)

评论内容(大于5个字符):

  1. Your Name yyyy-MM-dd HH:mm:ss

使用Live Messenger联系我