Agile Principles, Patterns, and Practices in C#
Now let's consider a slightly more significant example: the traditional automated teller machine (ATM) problem. The user interface of an ATM needs to be very flexible. The output may need to be translated into many different languages and it may need to be presented on a screen, on a braille tablet, or spoken out a speech synthesizer (Figure 12-4). Clearly, this flexibility can be achieved by creating an abstract base class that has abstract methods for all the different messages that need to be presented by the interface. Figure 12-4. ATM user interface
Consider also that each transaction that the ATM can perform is encapsulated as a derivative of the class transaction. Thus, we might have such classes as DepositTransaction, WithdrawalTransaction, transferTransaction, and so on. Each of these classes invokes UI methods. For example, in order to ask the user to enter the amount to be deposited, the DepositTransaction object invokes the RequestDepositAmount method of the UI class. Likewise, in order to ask the user how much money to transfer between accounts, the transferTransaction object calls the RequestTransferAmount method of UI. This corresponds to the diagram in Figure 12-5. Figure 12-5. ATM transaction hierarchy
Note that this is precisely the situation that ISP tells us to avoid. Each of the transactions is using UI methods that no other class uses. This creates the possibility that changes to one of the derivatives of TRansaction will force corresponding change to UI, thereby affecting all the other derivatives of transaction and every other class that depends on the UI interface. Something smells like rigidity and fragility around here. For example, if we were to add a PayGasBillTransaction, we would have to add new methods to UI in order to deal with the unique messages that this transaction would want to display. Unfortunately, since DepositTransaction, WithdrawalTransaction, and transferTransaction all depend on the UI interface, they are all likely to be rebuilt. Worse, if the transactions were all deployed as components in separate assemblies, those assemblies would very likely have to be redeployed, even though none of their logic was changed. Can you smell the viscosity? This unfortunate coupling can be avoided by segregating the UI interface into individual interfaces, such as DepositUI, WithdrawUI, and TRansferUI. These separate interfaces can then be multiply inherited into the final UI interface. Figure 12-6 and Listing 12-6 show this model. Figure 12-6. Segregated ATM UI interface
Whenever a new derivative of the transaction class is created, a corresponding base class for the abstract UI interface will be needed, and so the UI interface and all its derivatives must change. However, these classes are not widely used. Indeed, they are probably used only by main or whatever process boots the system and creates the concrete UI instance. So the impact of adding new UI base classes is minimized. A careful examination of Figure 12-6 shows one of the issues with ISP conformance that was not obvious from the TimedDoor example. Note that each transaction must somehow know about its particular version of the UI. DepositTransaction must know about DepositUI, WithdrawTransaction must know about WithdrawalUI, and so on. In Listing 12-6, I have addressed this issue by forcing each transaction to be constructed with a reference to its particular UI. Note that this allows me to use the idiom in Listing 12-7. This is handy but also forces each transaction to contain a reference member to its UI. In C#, one might be tempted to put all the UI components into a single class. Listing 12-8 shows such an approach. This, however, has an unfortunate effect. The UIGlobals class depends on DepositUI, WithdrawalUI, and TRansferUI. This means that a module wishing to use any of the UI interfaces transitively depends on all of them, exactly the situation that ISP warns us to avoid. If a change is made to any of the UI interfaces, all modules that use UIGlobals may be forced to recompile. The UIGlobals class has recombined the interfaces that we had worked so hard to segregate! Listing 12-6. Segregated ATM UI interface
Listing 12-7. Interface initialization idiom
Listing 12-8. Wrapping the Globals in a class
Consider now a function g that needs access to both the DepositUI and the transferUI. Consider also that we wish to pass the user interfaces into this function. Should we write the function declaration like this: void g(DepositUI depositUI, TransferUI transferUI) Or should we write it like this: void g(UI ui)
The temptation to write the latter (monadic) form is strong. After all, we know that in the former (polyadic) form, both arguments will refer to the same object. Moreover, if we were to use the polyadic form, its invocation might look like this: g(ui, ui);
Somehow this seems perverse. Perverse or not, the polyadic form is often preferable to the monadic form. The monadic form forces g to depend on every interface included in UI. Thus, when WithdrawalUI changes, g and all clients of g could be affected. This is more perverse than g(ui,ui)! Moreover, we cannot be sure that both arguments of g will always refer to the same object! In the future, it may be that the interface objects are separated for some reason. The fact that all interfaces are combined into a single object is information that g does not need to know. Thus, I prefer the polyadic form for such functions. Clients can often be grouped together by the service methods they call. Such groupings allow segregated interfaces to be created for each group instead of for each client. This greatly reduces the number of interfaces that the service has to realize and prevents the service from depending on each client type. Sometimes, the methods invoked by different groups of clients will overlap. If the overlap is small, the interfaces for the groups should remain separate. The common functions should be declared in all the overlapping interfaces. The server class will inherit the common functions from each of those interfaces but will implement them only once. When object-oriented applications are maintained, the interfaces to existing classes and components often change. Sometimes, these changes have a huge impact and force the recompilation and redeployment of a very large part of the system. This impact can be mitigated by adding new interfaces to existing objects rather than changing the existing interface. If clients of the old interface wish to access methods of the new interface, they can query the object for that interface, as shown in Listing 12-9. Listing 12-9.
As with all principles, care must be taken not to overdo it. The specter of a class with hundreds of different interfaces, some segregated by client and other segregated by version, is frightening indeed. |
Категории