영어로 읽는 코딩

51_[자바] 추상클라스와 인터페이스 (어뎁터와 팩토리 디자인 패턴 이용)

Abstract classes and methods

In all the “instrument” examples in the previous chapter, the methods in the base class Instrument were always “dummy” methods. If these methods are ever called, you’ve done something wrong. That’s because the intent of Instrument is to create a common interface for all the classes derived from it.

In those examples, the only reason to establish this common interface is so that it can be expressed differently for each different subtype. It establishes a basic form, so that you can say what’s common for all the derived classes. Another way of saying this is to call Instrument an abstract base class, or simply an abstract class.

If you have an abstract class like Instrument, objects of that specific class almost always have no meaning. You create an abstract class when you want to manipulate a set of classes through its common interface. Thus, Instrument is meant to express only the interface, and not a particular implementation, so creating an Instrument object makes no sense, and you’ll probably want to prevent the user from doing it. This can be accomplished by making all methods in Instrument generate errors, but that delays the information until run time and requires reliable exhaustive testing on the user’s part. It’s usually better to catch problems at compile time.

Java provides a mechanism for doing this called the abstract method. This is a method that is incomplete; it has only a declaration and no method body. Here is the syntax for an abstract method declaration.

abstract void f( );

A class containing abstract methods is called an abstract class. If a class contains one or more abstract methods, the class itself must be qualified as abstract. (Otherwise, the compiler gives you an error message.)

If an abstract class is incomplete, what is the compiler supposed to do when someone tries to make an object of that class? It cannot safely create an object of an abstract class, so you get an error message from the compiler. This way, the compiler ensures the purity of the abstract class, and you don’t need to worry about misusing it.

If you inherit from an abstract class and you want to make objects of the new type, you must provide method definitions for all the abstract methods in the base class. If you don’t (and you may choose not to), then the derived class is also abstract, and the compiler will force you to qualify that class with the abstract keyword.

It’s possible to make a class abstract without including any abstract methods. This is useful when you’ve got a class in which it doesn’t make sense to have any abstract methods, and yet you want to prevent any instances of that class.

The Instrument class from the previous chapter can easily be turned into an abstract class. Only some of the methods will be abstract, since making a class abstract doesn’t force you to make all the methods abstract. Here’s what it looks like:

Here’s the orchestra example modified to use abstract classes and methods:

//: interfaces/music4/Music4.java
// Abstract classes and methods.
package interfaces.music4;
import polymorphism.music.Note;
import static net.mindview.util.Print.*;
abstract class Instrument {
    private int i; // Storage allocated for each
	public abstract void play(Note n);
	public String what() { return "Instrument"; }
	public abstract void adjust();
}
class Wind extends Instrument {
	public void play(Note n) {
		print("Wind.play() " + n);
	}
	public String what() { return "Wind"; }
	public void adjust() {}
}
class Percussion extends Instrument {
	public void play(Note n) {
		print("Percussion.play() " + n);
	}
	public String what() { return "Percussion"; }
	public void adjust() {}
}
class Stringed extends Instrument {
	public void play(Note n) {
		print("Stringed.play() " + n);
	}
	public String what() { return "Stringed"; }
	public void adjust() {}
}
class Brass extends Wind {
	public void play(Note n) {
		print("Brass.play() " + n);
	}
	public void adjust() { print("Brass.adjust()"); }
}
class Woodwind extends Wind {
	public void play(Note n) {
		print("Woodwind.play() " + n);
	}
	public String what() { return "Woodwind"; }
}
public class Music4 {
	// Doesn’t care about type, so new types
	// added to the system still work right:
	static void tune(Instrument i) {
		// ...
		i.play(Note.MIDDLE_C);
	}
	static void tuneAll(Instrument[] e) {
		for(Instrument i : e)
			tune(i);
	}
	public static void main(String[] args) {
	// Upcasting during addition to the array:
		Instrument[] orchestra = {
			new Wind(),
			new Percussion(),
			new Stringed(),
			new Brass(),
			new Woodwind()
		};
		tuneAll(orchestra);
	}
} /* Output:
Wind.play() MIDDLE_C
Percussion.play() MIDDLE_C
Stringed.play() MIDDLE_C
Brass.play() MIDDLE_C
Woodwind.play() MIDDLE_C
*///:~

You can see that there’s really no change except in the base class.

It’s helpful to create abstract classes and methods because they make the abstractness of a class explicit, and tell both the user and the compiler how it was intended to be used. Abstract classes are also useful refactoring tolls, since they allow you to easily move common methods up the inheritance hierarchy.

 

Interfaces

The interface keyword takes the concept of abstractness one step further. The abstract keyword allows you to create one or more undefined methods in a class—you provide part of the interface without providing a corresponding implementation. The implementation is provided by inheritors. The interface keyword produces a completely abstract class, one that provides no implementation at all. It allows the creator to determine method names, argument lists, and return types, but no method bodies. An interface provides only a form, but no implementation.

An interface says, "All classes that implement this particular interface will look like this." Thus, any code that uses a particular interface knows what methods might be called for that interface, and that’s all. So the interface is used to establish a "protocol" between classes. (Some object-oriented programming languages have a keyword called protocol to do the same thing.)

However, an interface is more than just an abstract class taken to the extreme, since it allows you to perform a variation of "multiple inheritance" by creating a class that can be upcast to more than one base type.

To create an interface, use the interface keyword instead of the class keyword. As with a class, you can add the public keyword before the interface keyword (but only if that interface is defined in a file of the same name). If you leave off the public keyword, you get package access, so the interface is only usable within the same package. An interface can also contain fields, but these are implicitly static and final.

To make a class that conforms to a particular interface (or group of interfaces), use the implements keyword, which says, "The interface is what it looks like, but now I’m going to say how it works." Other than that, it looks like inheritance. The diagram for the instrument example shows this:

You can see from the Woodwind and Brass classes that once you’ve implemented an interface, that implementation becomes an ordinary class that can be extended in the regular way.

You can choose to explicitly declare the methods in an interface as public, but they are public even if you don’t say it. So when you implement an interface, the methods from the interface must be defined as public. Otherwise, they would default to package access, and you’d be reducing the accessibility of a method during inheritance, which is not allowed by the Java compiler.

You can see this in the modified version of the Instrument example. Note that every method in the interface is strictly a declaration, which is the only thing the compiler allows. In addition, none of the methods in Instrument are declared as public, but they’re automatically public anyway:

//: interfaces/music5/Music5.java
// Interfaces.
package interfaces.music5;
import polymorphism.music.Note;
import static net.mindview.util.Print.*;
interface Instrument {
    // Compile-time constant:
	int VALUE = 5; // static & final
	// Cannot have method definitions:
	void play(Note n); // Automatically public
	void adjust();
}
class Wind implements Instrument {
	public void play(Note n) {
		print(this + ".play() " + n);
	}
	public String toString() { return "Wind"; }
	public void adjust() { print(this + ".adjust()"); }
}
class Percussion implements Instrument {
	public void play(Note n) {
		print(this + ".play() " + n);
	}
	public String toString() { return "Percussion"; }
	public void adjust() { print(this + ".adjust()"); }
}
class Stringed implements Instrument {
	public void play(Note n) {
		print(this + ".play() " + n);
	}
	public String toString() { return "Stringed"; }
	public void adjust() { print(this + ".adjust()"); }
}
class Brass extends Wind {
	public String toString() { return "Brass"; }
}
class Woodwind extends Wind {
	public String toString() { return "Woodwind"; }
}
public class Music5 {
	// Doesn’t care about type, so new types
	// added to the system still work right:
	static void tune(Instrument i) {
		// ...
		i.play(Note.MIDDLE_C);
	}
	static void tuneAll(Instrument[] e) {
	for(Instrument i : e)
		tune(i);
}
public static void main(String[] args) {
	// Upcasting during addition to the array:
	Instrument[] orchestra = {
		new Wind(),
		new Percussion(),
		new Stringed(),
		new Brass(),
		new Woodwind()
	};
	tuneAll(orchestra);
	}
} /* Output:
Wind.play() MIDDLE_C
Percussion.play() MIDDLE_C
Stringed.play() MIDDLE_C
Brass.play() MIDDLE_C
Woodwind.play() MIDDLE_C
*///:~

One other change has been made to this version of the example: The what( ) method has been changed to toString( ), since that was how the method was being used. Since toString( ) is part of the root class Object, it doesn’t need to appear in the interface.

The rest of the code works the same. Notice that it doesn’t matter if you are upcasting to a "regular" class called Instrument, an abstract class called Instrument, or to an interface called Instrument. The behavior is the same. In fact, you can see in the tune( ) method that there isn’t any evidence about whether Instrument is a "regular" class, an abstract class, or an interface.

 

Complete decoupling

Whenever a method works with a class instead of an interface, you are limited to using that class or its subclasses. If you would like to apply the method to a class that isn’t in that hierarchy, you’re out of luck. An interface relaxes this constraint considerably. As a result, it allows you to write more reusable code.

For example, suppose you have a Processor class that has a name( ) and a process( ) method that takes input, modifies it and produces output. The base class is extended to create different types of Processor. In this case, the Processor subtypes modify String objects (note that the return types can be covariant, but not the argument types):

//: interfaces/classprocessor/Apply.java
package interfaces.classprocessor;
import java.util.*;
import static net.mindview.util.Print.*;
class Processor {
    public String name() {
		return getClass().getSimpleName();
	}
	Object process(Object input) { return input; }
}
class Upcase extends Processor {
	String process(Object input) { // Covariant return
		return ((String)input).toUpperCase();
	}
}
class Downcase extends Processor {
	String process(Object input) {
		return ((String)input).toLowerCase();
	}
}
class Splitter extends Processor {
	String process(Object input) {
		// The split() argument divides a String into pieces:
		return Arrays.toString(((String)input).split(" "));
	}
}
public class Apply {
	public static void process(Processor p, Object s) {
		print("Using Processor " + p.name());
		print(p.process(s));
	}
	public static String s =
		"Disagreement with beliefs is by definition incorrect";
	public static void main(String[] args) {
		process(new Upcase(), s);
		process(new Downcase(), s);
		process(new Splitter(), s);
	}
} /* Output:
Using Processor Upcase
DISAGREEMENT WITH BELIEFS IS BY DEFINITION INCORRECT
Using Processor Downcase
disagreement with beliefs is by definition incorrect
Using Processor Splitter
[Disagreement, with, beliefs, is, by, definition, incorrect]
*///:~

The Apply.process( ) method takes any kind of Processor and applies it to an Object, then prints the results. Creating a method that behaves differently depending on the argument object that you pass it is called the Strategy design pattern. The method contains the fixed part of the algorithm to be performed, and the Strategy contains the part that varies. The Strategy is the object that you pass in, and it contains code to be executed. Here, the Processor object is the Strategy, and in main( ) you can see three different Strategies applied to the String s.

The split( ) method is part of the String class. It takes the String object and splits it using the argument as a boundary, and returns a String[ ]. It is used here as a shorter way of creating an array of String.

Now suppose you discover a set of electronic filters that seem like they could fit into your Apply.process( ) method:

//: interfaces/filters/Waveform.java
package interfaces.filters;
public class Waveform {
    private static long counter;
	private final long id = counter++;
	public String toString() { return "Waveform " + id; }
} ///:~
//: interfaces/filters/Filter.java
package interfaces.filters;
public class Filter {
	public String name() {
		return getClass().getSimpleName();
	}
	public Waveform process(Waveform input) { return input; }
} ///:~
//: interfaces/filters/LowPass.java
package interfaces.filters;
public class LowPass extends Filter {
	double cutoff;
	public LowPass(double cutoff) { this.cutoff = cutoff; }
	public Waveform process(Waveform input) {
		return input; // Dummy processing
	}
} ///:~
//: interfaces/filters/HighPass.java
package interfaces.filters;
public class HighPass extends Filter {
	double cutoff;
	public HighPass(double cutoff) { this.cutoff = cutoff; }
	public Waveform process(Waveform input) { return input; }
} ///:~
//: interfaces/filters/BandPass.java
package interfaces.filters;
public class BandPass extends Filter {
	double lowCutoff, highCutoff;
	public BandPass(double lowCut, double highCut) {
		lowCutoff = lowCut;
		highCutoff = highCut;
	}
	public Waveform process(Waveform input) { return input; }
} ///:~

Filter has the same interface elements as Processor, but because it isn’t inherited from Processor—because the creator of the Filter class had no clue you might want to use it as a Processor—you can’t use a Filter with the Apply.process( ) method, even though it would work fine. Basically, the coupling between Apply.process( ) and Processor is stronger than it needs to be, and this prevents the Apply.process( ) code from being reused when it ought to be. Also notice that the inputs and outputs are both Waveforms.

If Processor is an interface, however, the constraints are loosened enough that you can reuse an Apply.process( ) that takes that interface. Here are the modified versions of Processor and Apply:

//: interfaces/interfaceprocessor/Processor.java
package interfaces.interfaceprocessor;
public interface Processor {
    String name();
	Object process(Object input);
} ///:~
//: interfaces/interfaceprocessor/Apply.java
package interfaces.interfaceprocessor;
import static net.mindview.util.Print.*;
public class Apply {
	public static void process(Processor p, Object s) {
		print("Using Processor " + p.name());
		print(p.process(s));
	}
} ///:~

The first way you can reuse code is if client programmers can write their classes to conform to the interface, like this:

//: interfaces/interfaceprocessor/StringProcessor.java
package interfaces.interfaceprocessor;
import java.util.*;
public abstract class StringProcessor implements Processor{
    public String name() {
		return getClass().getSimpleName();
	}
	public abstract String process(Object input);
	public static String s =
		"If she weighs the same as a duck, she’s made of wood";
	public static void main(String[] args) {
		Apply.process(new Upcase(), s);
		Apply.process(new Downcase(), s);
		Apply.process(new Splitter(), s);
	}
}
class Upcase extends StringProcessor {
	public String process(Object input) { // Covariant return
		return ((String)input).toUpperCase();
	}
}
class Downcase extends StringProcessor {
	public String process(Object input) {
		return ((String)input).toLowerCase();
	}
}
class Splitter extends StringProcessor {
	public String process(Object input) {
		return Arrays.toString(((String)input).split(" "));
	}
} /* Output:
Using Processor Upcase
IF SHE WEIGHS THE SAME AS A DUCK, SHE’S MADE OF WOOD
Using Processor Downcase
if she weighs the same as a duck, she’s made of wood
Using Processor Splitter
[If, she, weighs, the, same, as, a, duck,, she’s, made, of, wood]
*///:~

However, you are often in the situation of not being able to modify the classes that you want to use. In the case of the electronic filters, for example, the library was discovered rather than created. In these cases, you can use the Adapter design pattern. In Adapter, you write code to take the interface that you have and produce the interface that you need, like this:

//: interfaces/interfaceprocessor/FilterProcessor.java
package interfaces.interfaceprocessor;
import interfaces.filters.*;
class FilterAdapter implements Processor {
    Filter filter;
	public FilterAdapter(Filter filter) {
		this.filter = filter;
	}
	public String name() { return filter.name(); }
	public Waveform process(Object input) {
		return filter.process((Waveform)input);
	}
}
public class FilterProcessor {
	public static void main(String[] args) {
		Waveform w = new Waveform();
		Apply.process(new FilterAdapter(new LowPass(1.0)), w);
		Apply.process(new FilterAdapter(new HighPass(2.0)), w);
		Apply.process(
			new FilterAdapter(new BandPass(3.0, 4.0)), w);
	}
} /* Output:
Using Processor LowPass
Waveform 0
Using Processor HighPass
Waveform 0
Using Processor BandPass
Waveform 0
*///:~

In this approach to Adapter, the FilterAdapter constructor takes the interface that you have—Filter—and produces an object that has the Processor interface that you need. You may also notice delegation in the FilterAdapter class.

Decoupling interface from implementation allows an interface to be applied to multiple different implementations, and thus your code is more reusable.

 

Interfaces and factories

An interface is intended to be a gateway to multiple implementations, and a typical way to produce objects that fit the interface is the Factory Method design pattern. Instead of calling a constructor directly, you call a creation method on a factory object which produces an implementation of the interface—this way, in theory, your code is completely isolated from the implementation of the interface, thus making it possible to transparently swap one implementation for another. Here’s a demonstration showing the structure of the Factory Method:

//: interfaces/Factories.java
import static net.mindview.util.Print.*;
interface Service {
    void method1();
	void method2();
}
interface ServiceFactory {
	Service getService();
}
class Implementation1 implements Service {
	Implementation1() {} // Package access
	public void method1() {print("Implementation1 method1");}
	public void method2() {print("Implementation1 method2");}
}
class Implementation1Factory implements ServiceFactory {
	public Service getService() {
		return new Implementation1();
	}
}
class Implementation2 implements Service {
	Implementation2() {} // Package access
	public void method1() {print("Implementation2 method1");}
	public void method2() {print("Implementation2 method2");}
}
class Implementation2Factory implements ServiceFactory {
	public Service getService() {
		return new Implementation2();
	}
}
public class Factories {
	public static void serviceConsumer(ServiceFactory fact) {
	Service s = fact.getService();
		s.method1();
		s.method2();
	}
	public static void main(String[] args) {
		serviceConsumer(new Implementation1Factory());
		// Implementations are completely interchangeable:
		serviceConsumer(new Implementation2Factory());
	}
} /* Output:
Implementation1 method1
Implementation1 method2
Implementation2 method1
Implementation2 method2
*///:~

Without the Factory Method, your code would somewhere have to specify the exact type of Service being created, so that it could call the appropriate constructor.

Why would you want to add this extra level of indirection? One common reason is to create a framework. Suppose you are creating a system to play games; for example, to play both chess and checkers on the same board:

//: interfaces/Games.java
// A Game framework using Factory Methods.
import static net.mindview.util.Print.*;
interface Game { boolean move(); }
interface GameFactory { Game getGame(); }
class Checkers implements Game {
    private int moves = 0;
	private static final int MOVES = 3;
	public boolean move() {
		print("Checkers move " + moves);
		return ++moves != MOVES;
	}
}
class CheckersFactory implements GameFactory {
	public Game getGame() { return new Checkers(); }
}
class Chess implements Game {
	private int moves = 0;
	private static final int MOVES = 4;
	public boolean move() {
		print("Chess move " + moves);
		return ++moves != MOVES;
	}
}
class ChessFactory implements GameFactory {
	public Game getGame() { return new Chess(); }
}
public class Games {
	public static void playGame(GameFactory factory) {
		Game s = factory.getGame();
		while(s.move())
			;
	}
	public static void main(String[] args) {
		playGame(new CheckersFactory());
		playGame(new ChessFactory());
	}
} /* Output:
Checkers move 0
Checkers move 1
Checkers move 2
Chess move 0
Chess move 1
Chess move 2
Chess move 3
*///:~

If the Games class represents a complex piece of code, this approach allows you to reuse that code with different types of games. You can imagine more elaborate games that can benefit from this pattern. In the next chapter, you’ll see a more elegant way to implement the factories using anonymous inner classes.

 

Summary

It is tempting to decide that interfaces are good, and therefore you should always choose interfaces over concrete classes. Of course, almost anytime you create a class, you could instead create an interface and a factory.

Many people have fallen to this temptation, creating interfaces and factories wherever it’s possible. The logic seems to be that you might need to use a different implementation, so you should always add that abstraction. It has become a kind of premature design optimization.

Any abstraction should be motivated by a real need. Interfaces should be something you refactor to when necessary, rather than installing the extra level of indirection everywhere, along with the extra complexity. That extra complexity is significant, and if you make someone work through that complexity only to realize that you’ve added interfaces "just in case" and for no compelling reason—well, if I see such a thing I begin to question all the designs that this particular person has done.

An appropriate guideline is to prefer classes to interfaces. Start with classes, and if it becomes clear that interfaces are necessary, then refactor. Interfaces are a great tool, but they can easily be overused.

 

[Thinking in Java, 219~]

 

 

댓글

댓글 본문
버전 관리
Yoo Moon Il
현재 버전
선택 버전
graphittie 자세히 보기