合成和继承
在Java 101:Java的继承(第1部分)中 ,您学习了如何通过在类之间建立is-a关系来利用继承进行代码重用。 组合是一种紧密相关的编程技术,用于建立has-a关系。 继承将一类的功能扩展到另一类 ,而组合则允许我们从另一类构成一个类。 区别一开始是微妙的,但是一旦您在代码中看到它,它就会变得更加有意义。
组成与继承
有-有-是-有关系
在组合中,一个类具有一个类型与另一个类相同的字段。 例如, Vehicle
有一个名为make
的String
字段。 它也可以有一个Engine
场名为engine
和Transmission
领域命名的transmission
:
class Vehicle
{
private String make;
private Engine engine;
private Transmission transmission;
// ...
}
class Transmission
{
// ...
}
class Engine
{
// ...
}
在此示例中,我们可以说车辆是由品牌,发动机和变速器组成的,因为它具有make
领域, engine
领域和transmission
领域。
除了组成其他类的类之外,还可以通过将对象引用存储在另一个对象的字段中来使用此技术来组成其他对象的对象。
继承破坏封装
继承是有问题的,因为它破坏了封装。 您会从Java 101中回想起:Java中的类和对象 封装是指将构造函数,字段和方法组合到类的主体中。 在继承中,子类依赖于其超类中的实现细节。 如果超类的实现细节发生更改,则子类可能会中断。 当开发人员无法完全控制超类时,或者在设计和记录超类时并没有考虑扩展性时,此问题尤其严重。 (有关使用超类的更多信息,请参见Java 101:Java中的继承 。)
如何破坏子类:一个例子
为了理解这个问题,假设您已经购买了实现联系人管理器的Java类库。 尽管您没有访问它们的源代码的权限,但假定清单1描述了主要的CM
类。
清单1.实现联系人管理器的一部分
public class CM
{
private final static int MAX_CONTACTS = 1000;
private Contact[] contacts;
private int size;
public CM()
{
contacts = new Contact[MAX_CONTACTS];
size = 0; // redundant because size is automatically initialized to 0
// adds clarity, however
}
public void addContact(Contact contact)
{
if (size == contacts.length)
return; // array is full
contacts[size++] = contact;
}
public void addContacts(Contact[] contacts)
{
for (int i = 0; i < contacts.length; i++)
addContact(contacts[i]);
}
}
CM
类存储联系人数组,每个联系人由Contact
实例描述。 对于此讨论,“ Contact
的详细信息并不重要。 它可能与public class Contact {}
一样琐碎。
宣布公开课
如果您想知道为什么将CM
声明为public
,那是因为我相信CM
的将来版本可以存储在一个包中,也称为库。 在程序包中,外部应用程序只能访问public
类。 (被设计为支持public
类并且不能被应用程序访问的Helper类没有被声明为public
。)相同的原理也适用于构造函数和方法,这就是为什么我将它们声明为public
。
现在,假设您想将每个联系人记录在文件中。 由于不提供日志记录功能,因此可以使用清单2的LoggingCM
类扩展CM
,该类在重写addContact()
和addContacts()
方法中添加了日志记录行为。
清单2.扩展联系人管理器以支持日志记录
public class LoggingCM extends CM
{
// A constructor is not necessary because the Java compiler will add a
// no-argument constructor that calls the superclass's no-argument
// constructor by default.
@Override
public void addContact(Contact contact)
{
Logger.log(contact.toString());
super.addContact(contact);
}
@Override
public void addContacts(Contact[] contacts)
{
for (int i = 0; i < contacts.length; i++)
Logger.log(contacts[i].toString());
super.addContacts(contacts);
}
}
LoggingCM
类依赖于Logger
类(请参见清单3),该类的void log(String msg)
类方法将字符串记录到文件中。 Contact
对象通过toString()
转换为字符串,然后传递给log()
。
清单3. log()
其参数输出到标准输出流
class Logger
{
static void log(String msg)
{
System.out.println(msg);
}
}
尽管LoggingCM
看起来还不错,但它并没有像您期望的那样工作。 假设您实例化了此类,并通过addContacts()
向该对象添加了一些Contact
对象:
清单4.继承问题
class CMDemo
{
public static void main(String[] args)
{
Contact[] contacts = { new Contact(), new Contact(), new Contact() };
LoggingCM lcm = new LoggingCM();
lcm.addContacts(contacts);
}
}
如果运行此代码,您将发现log()
总共输出六条消息。 问题是预期的三则消息(每个Contact
对象一个)中的每一个都重复了。
发生了什么?
LoggingCM
的addContacts()
方法时,它首先为contacts
数组中传递给addContacts()
每个Contact
实例调用Logger.log()
addContacts()
。 然后,此方法通过super.addContacts(contacts);
调用CM
的addContacts()
方法super.addContacts(contacts);
。
CM
的addContacts()
方法调用LoggingCM
的重写addContact()
方法,该方法用于其contacts
数组参数中的每个Contact
实例。 然后, addContact()
方法执行Logger.log(contact.toString());
,以记录其contact
参数的字符串表示形式,最后得到三个附加的已记录消息。
方法重写和脆弱的基类问题
如果您不重写addContacts()
方法,此问题将消失。 但是在那种情况下,子类仍将与实现细节联系在一起: CM
的addContacts()
方法调用addContact()
。
如果没有详细记录实现细节,则不要依赖该细节。 (请记住,您无权访问CM
的源代码。)如果未记录详细信息,则可以在该类的新版本中对其进行更改。
因为基类的更改可能会破坏子类,所以此问题被称为脆弱的基类问题 。 当在后续发行版中将新方法添加到超类中时,就会发生相关的脆弱性原因(这也与覆盖方法有关)。
例如,假设该库的新版本在CM
类中引入了一个public void addContact(Contact contact, boolean unique)
方法。 当unique
为false
时,此方法将contact
实例添加到联系人管理器。 如果unique
为true
,则仅在先前未添加联系人实例的情况下才添加联系人实例。
由于此方法是在创建LoggingCM
类之后添加的,因此LoggingCM
不会通过调用Logger.log()
来覆盖新的addContact()
方法。 结果,不会记录传递给新的addContact()
方法的Contact
实例。
这是另一个问题:您在子类中引入了一个不在父类中的方法。 新版本的超类提供了与子类方法签名和返回类型相匹配的新方法。 您的子类方法现在将覆盖超类方法,并且可能无法满足超类方法的约定。
撰写(并转发)以进行救援
幸运的是,您可以使所有这些问题都消失。 不必扩展超类,而是在新类中创建一个private
字段,并使该字段引用超类的实例 。 此解决方法需要在新类和超类之间形成一个has-a关系,因此您使用的技术是组合。
此外,您可以使每个新类的实例方法调用相应的超类方法,并返回被调用方法的返回值。 您可以通过保存在private
字段中的超类实例来执行此操作。 此任务称为转发 ,新方法称为转发方法 。
清单5展示了一个改进的LoggingCM
类,该类使用组合和转发来永远消除脆弱的基类问题和无法预料的方法重写的附加问题。
清单5.组成和方法转发演示
public class LoggingCM
{
private CM cm;
public LoggingCM(CM cm)
{
this.cm = cm;
}
public void addContact(Contact contact)
{
Logger.log(contact.toString());
cm.addContact(contact);
}
public void addContacts(Contact[] contacts)
{
for (int i = 0; i < contacts.length; i++)
Logger.log(contacts[i].toString());
cm.addContacts(contacts);
}
}
请注意,在此示例中, LoggingCM
类不依赖于CM
类的实现细节。 您可以在不破坏LoggingCM
情况下向CM
添加新方法。
包装器类和装饰器设计模式
清单5的LoggingCM
类是包装器类的示例, 该类是其实例包装其他实例的类。 每个LoggingCM
对象都包装一个CM
对象。 LoggingCM
也是Decorator设计模式的一个示例。
要使用新的LoggingCM
类,您必须首先实例化CM
并将结果对象作为参数传递给LoggingCM
的构造函数。 LoggingCM
对象包装CM
对象,如下所示:
LoggingCM lcm = new LoggingCM(new CM());
结论
在此Java技巧中,您学习了组合和继承之间的区别,以及如何使用组合来组合其他类中的类。 您还了解到,组合解决了与继承相关的主要编程挑战之一,那就是它破坏了封装。
对于将来的开发人员不太可能访问或控制超类的情况,组合是一种重要的编程技术。 对于没有设计和记录扩展的类包或库的情况,这是一项特别关键的技术。
设计和记录类扩展是什么意思? 设计意味着提供protected
方法,这些方法可以挂接到类的内部工作中(以支持编写有效的子类),并确保构造函数和clone()
方法从不调用可重写的方法。 文档意味着清楚地描述覆盖方法的影响。
您可能还想知道什么时候应该扩展类或使用包装器。 当超类和子类之间存在is -a关系并且您可以控制超类或为类扩展设计和记录超类时,扩展类。 否则,使用包装器类。
最后,您可能听说过,您不应该在回调框架中使用包装器类,该框架是一个对象框架,在该框架中,对象将自己的引用传递给另一个对象(通过this
),以便后者可以在以后调用前者的方法。时间- 回调 。 在这种情况下,您不应该使用包装器类,因为被包装的对象不知道其包装器类(它仅通过this
传递其引用),并且产生的回调方法不会调用包装器类的方法。
这个故事“何时使用合成与继承”最初由JavaWorld发布 。
翻译自: https://www.infoworld.com/article/2990828/java-101-primer-composition-and-inheritance.html
合成和继承