今天遇到一问题,找到下面的两篇文章,研究比较深入,特转了一下,
最近除了搞ASP.NET MVC之外,我也在思考一些编程实践方面的问题。昨天在回家路上,我忽然对一个问题产生了较为清晰的认识。或者说,原先只是有一丝细微的感觉,而现在将它和一些其他的方面进行了联系,也显得颇为“完备”。这就是问题便是:如何对待类中internal成员。我现在认为“类中的internal成员可能是一个坏味道”,换句话说,如果您的类中出现了internal的成员,就可能是设计上的问题了。
可能这个命题说得还有些笼统,所以再详细地描述一下比较妥当。我的意思是,您的类库中出现internal的类型是完全没有问题的(也肯定是无法避免的)。然而,一个经过良好设计的类型,是应该很少出现internal的方法或属性的(字段就不在考虑范围,因为它应该永远是私有的)。其中有例外,如“构造函数”的修饰级别,稍后会再谈到。
C#中一个类中的成员有四种修饰级别:
- public:完全开放,谁都能访问。
- private:完全封闭,只有类自身可以访问。
- internal:只对相同程序集,或使用InternalVisibleToAttribute标记的程序集开放。
- protected:只对子类开放。
您也可以将protected和internal修饰同一个成员,这使得类中的一个成员可以拥有5种不同的访问权限。我认为,其中pubic、private和protected级别的含义是清晰而纯粹的,而internal的开放程度则是像是一个“灰色地带”。
Internal类中的Internal成员
我们为什么会使用internal修饰符?最简单的答案,自然是为了让相同程序集内类型可以访问,但是不对外部开放。那么我们什么时候会用这种访问级别呢?可能是这样的:
internal class SomeClass { internal void SomeMethod() { } }
请注意,这里我们在一个internal的类型中使用了internal来修饰这个方法。这是一种累赘,因为它和public修饰效果完全一致,这会造成不清晰的修饰性(灰色地带)。因此,在internal类型中,所有的成员只能是public、private和protected访问级别。也就是说,上面的代码应该改成:
internal class SomeClass { public void SomeMethod() { } }
于是,内部类中哪些是私有的,哪些是公开的(可以被相同程序集内访问到)一目了然。这个类的职责也非常明确。
Public类的Internal成员
这个问题就麻烦了许多,因为此时类中的internal成员含义就非常明确了:
public class SomeClass { internal void SomeMethod() { } }
public类中的internal成员可以被相同程序集内的类型访问到,而对外部的程序集是隐藏的。这意味着,这个类的功能分了两部分,一部分对所有人公开,还有一部分对自己人公开,对其他人关闭。在很多时候,这可能意味着一个类拥有了两种职责,一种对外,一种对内,而这种情况显然违背了“单一职责原则”。这时候我们可能需要重构,把一部分对内的职责封装为额外的internal类型,并负责内部逻辑的交互。如此,代码可能就会写成这样:
internal class InternalClass { private SomeClass m_someClass; public InternalClass(SomeClass someClass) { this.m_someClass = someClass; } public void SomeMethod() { /* use data on this.m_someClass. */ } } public class SomeClass { // public members }
不过这可能也是最容易产生争议的地方,因为这“削减”了internal的相当一大部分作用,此外还会造成代码的增加。而事实上,很多时候也应该在public类中使用internal方法,只要不违背“单一职责原则”即可。不过我想,这方面的“权衡”应该也是较为容易的,因为基本上所有的考量都是基于“职责”的。
这也是我思考中经常遇到的问题,就是某种“实践”是不是属于“过度设计”了。我们的目标是快速发布,确保质量,而不是为了遵循原则而去遵循原则。在今后此类文章中,我也会提出类似的“权衡”,如果您有看法,欢迎和我交流。
为了单元测试而使用Internal成员
例如,一个类中有一个复杂的私有方法,我们希望对它进行单元测试。由于private成员无法被外部访问,因此我们会将其写成internal的方法:
public class SomeClass { public void SomeMethod() { // do something... this.ComplexMethod(); // do something else... } internal void ComplexMethod() { } }
由于是internal方法,我们可以使用InternalVisibleToAttribute释放给其他程序集,就可以在那个程序集中编写单元测试代码。但是我认为这个做法不好。
首先,我一直不喜欢为了“单元测试”而改变原有的封装性,即使改成internal成员后,对其他外部程序集来说并没有什么影响。 在MSDN Web Cast或其他一些地方,我可能讲过我们“可以”把private方法改为internal,仅仅是为单元测试。还有便是把protected也改成protected internal——我也会写文章讨论这个问题。
其实这又涉及到是否应该测试私有方法的问题,我最近会再对此进行较为详细的讨论。如果您有一个需要测试的复杂的私有方法,这意味着这个私有方法可能会有独立的职责,独立的算法。我们又值得将其独立提取出来:
internal class ComplexClass { public void ComplexMethod() { } } public class SomeClass { private ComplexClass m_complexClass = new ComplexClass(); public void SomeMethod() { // do something... this.m_complexClass.ComplexMethod(); // do something else... } }
由于ComplexClass是internal的,我们便可以为其进行独立的单元测试。
一些例外情况
万事都有例外。例如对于构造函数来说,internal在很多时候是一个“必须”的修饰符:
internal class ComplexClass { public virtual void ComplexMethod() { } } public class SomeClass { private ComplexClass m_complexClass; public SomeClass() : this(new ComplexClass()) { } internal SomeClass(ComplexClass complexClass) { this.m_complexClass = complexClass; } public void SomeMethod() { // do something... this.m_complexClass.ComplexMethod(); // do something else... } }
由于其中一个构造函数是internal的,并接受一个对象,因此单元测试便可以利用这个构造函数“注入”一个对象(往往是一个Mock对象)。而对外公开的构造函数,便可以直接提供一个具体的实例,作为真实场景中的使用方式。
上一篇文章里我讨论了一个类中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还是颇为相像的。