[모던 자바 인 액션] 람다로 리팩터링하기

신명철·2022년 10월 28일
1

모던 자바 인 액션

목록 보기
2/3

들어가며

람다의 이해와 활용을 공부하기 위해서 모던 자바 인 액션의 3장과 9장을 요약해 포스트를 작성하고자 한다.

함수형 인터페이스

@FunctionalInterface
public interface Predicate<T> {
	boolean test(T t);
}

함수형 인터페이스는 오직 하나의 추상 메서드만 갖는 인터페이스를 말한다. 함수형 인터페이스는 람다 표현식으로 추상 메서드를 직접 전달할 수 있기 때문에 전체 표현식을 함수형 인터페이스의 인스턴스로 취급할 수 있다.

함수형 인터페이스 종류

자바 API는 함수형 인터페이스로 여러가지를 제공하고 있다.

  • Predicate<T> : T 클래스를 받아서 boolean 리턴
  • Consumer<T> : T 클래스를 받아서 void 리턴
  • Function<T, R> : T 클래스를 받아서 R 클래스 리턴
  • supplier<T> : 입력값 없이 T 클래스 리턴
  • UnaryOperator<T> : T 클래스 받아서 T 클래스 리턴
  • BinaryOperator<T> : T 클래스를 2개 받아서 T 클래스 리턴
  • BiPredicate<L, T> : L, R 클래스 받아서 boolean으로 리턴
  • BiConsumer<T, U> : T, U 클래스 받아서 void 리턴
  • BiFunction<L, U, R> : T, U 클래스 받아서 R 클래스 리턴

위 함수형 인터페이스들 외에도 원시 타입을 지원하기 위해 존재하는 IntPredicate,IntConsumer,IntFunction 같은 함수형 인터페이스들도 존재한다. 원시 타입을 레퍼런스 타입으로 변환하면서 생기는 박싱 비용을 절감하기 위해 위 함수형 인터페이스 대신 사용할 수 있다.

람다의 동작

형식 검사

람다 표현식의 형식을 대상 형식 이라고 부른다. 이런 람다 표현식의 형식을 적합한지를 판단하는 것을 말한다.

List<Integer> list = Arrays.asList(1,2,3,4,5);
list.stream().filter(int -> int > 2).collect(Collectors.toList());

위와 같은 코드가 있을 때 형식 확인 과정은 다음과 같은 순서로 이루어진다.
1. filter 메서드의 선언을 확인한다
2. filter 의 파라미터가 Predicate<? super T> 라는 것을 확인한다
3. Predicate<? super T>는 T 클래스를 받아서 boolean 으로 리턴하는 함수형 인터페이스임을 인지한다
4. filter 의 파라미터로 전달되는 인수가 Predicate<? super T> 의 추상 메서드를 묘사할 수 있는지 확인한다.

형식 추론

람다는 형식 추론을 통해 람다 표현식을 더 단순화할 수 있도록 한다.

List<Integer> list = Arrays.asList(1,2,3,4,5);

list.stream().filter(int -> int > 2).collect(Collectors.toList()); // 형식 추론
list.stream().filter((Integer int) -> int > 2).collect(Collectors.toList()); // 형식 추론 X

지역 변수 사용

람다 표현식에서는 익명 함수가 하는 것처럼 자유 변수를 활용할 수 있다. 이와 같은 동작은 람다 캡처링이라고 부른다. 하지만 람다 캡처링에 사용되는 변수는 변수가 변하지 않는다는 명시적 혹은 암시적 전제 조건이 붙어야만 한다. 이는 근본적으로 지역 변수는 스택에 인스턴스 변수는 힙에 저장되는데에서 기인한다.

멀티스레드의 환경 등에서 스레드에서 람다가 실행될 때, 변수를 할당했던 스레드가 사라져서 변수 할당이 해제 되어도 람다는 해당 변수에 접근하려고 할 수 있기 때문에 변수를 할당할 때 local variable의 복사본을 제공한다.

따라서 이 복사본의 값이 변하지 않음을 보장하기 변수에 값을 단 한 번만 할당해야 한다는 제약이 생긴 것이다.

Integer num = 3; // 지역 변수
List<Integer> list = Arrays.asList(1,2,3,4,5);
list.stream().map(n -> n + num).collect(Collectors.toList());
num = 4; // 컴파일 에러 발생 !

자유 변수?
람다를 호출한 메서드가 종료되어도 람다는 메서드의 종료 여부와 관계없이 자유롭게 데이터가 유지되기 때문에 자유변수라고 부른다.


람다를 활용한 디자인 패턴 리팩터링

1. 전략 패턴

전략 패턴을 적용한 다음과 같은 코드가 있다고 가정해보자.

public interface ValidationStrategy {
	boolean execute(String s);
}

public class IsAllLowerCase implements ValidationStrategy{
	@Override
	public boolean execute(String s) {
		return s.matches("[a-z]+");
	}
}

public class IsNumeric implements ValidationStrategy{
	@Override
	public boolean execute(String s) {
		return s.matches("\\d+");
	}
}

public class Validator {
	private final ValidationStrategy strategy;

	public Validator(ValidationStrategy strategy) {
		this.strategy = strategy;
	}
    
	public boolean validate(String s) {
		return strategy.execute(s);
	}
}

public static void main(String[] args) {
	Validator numericValidator = new Validator(new IsNumeric());
	Validator lowerCaseValidator = new Validator(new IsAllLowerCase());		
}

위 전략 패턴에 Predicate 함수형 인터페이스를 적용하면 위와 같이 새로운 클래스를 구현할 필요가 없어진다.

public static void main(String[] args) {
	Validator numericValidator = new Validator((String s) -> s.matches("[a-z]+"));
	Validator lowerValidator = new Validator((String s) -> s.matches("\\d+"));
}

2. 템플릿 메서드

다음과 같이 게임의 템플릿 메서드가 존재한다고 가정해보자.

public abstract class Game {

	public void login(int id) {
		Member member = Database.getMemberWithId(id);
		execute(member);
	}

	abstract void execute(Member member);
}
  • 로그인을 하고, 각자 게임에 맞는 execute를 구현해서 실행한다.

위에 함수형 인터페이스인 Consumer를 적용하면 다음과 같아진다.

public abstract class Game {

	public void login(int id, Consumer<Member> execute) {
		Member member = Database.getMemberWithId(id);
		execute.accept(member);
	}
}

public static void main(String[] args) {
	new Game().login(1234, (Member m) -> System.out.println("Hello, " + m.getName()));
}

Game 클래스를 상속받은 클래스를 구현하지 않고도 직접 람다 표현식을 전달해서 다양한 동작을 추가할 수 있다.

3. 옵저버 패턴

맨유, 맨시티, 리버풀의 경기 결과를 알려주는 옵저버가 있다고 가정해보자. 다양한 옵저버를 그룹화할 Observer 인터페이스와 새로운 트윗이 발생했을 때 호출할 Subject 클래스가 있다.

public interface Observer {
	void notify(String tweet);
}

public class MUresult implements Observer{
	@Override
	public void notify(String tweet) {
		if(tweet!=null && tweet.contains("manchester united")) {
			System.out.println("MU news published !" + tweet);
		}
	}
}

public class LFCresult implements Observer{
	@Override
	public void notify(String tweet) {
		if(tweet!=null && tweet.contains("liverpool")) {
			System.out.println("LFC news published !" + tweet);
		}
	}
}

public class MCYresult implements Observer{
	@Override
	public void notify(String tweet) {
		if(tweet!=null && tweet.contains("manchester city")) {
			System.out.println("MCY news published !" + tweet);
		}
	}
}
public interface Subject {
	void registerObserver(Observer o);
	void notifyObservers(String tweet);
}

public class Feed implements Subject{
	private List<Observer> observers = new ArrayList<>();
	
	@Override
	public void registerObserver(Observer o) {
		this.observers.add(o);
	}

	@Override
	public void notifyObservers(String tweet) {
		observers.forEach(o -> o.notify(tweet));
	}
}
public class Main {
	public static void main(String[] args) {
		Feed f = new Feed();
		f.registerObserver(new MUresult());
		f.registerObserver(new LFCresult());
		f.registerObserver(new MCYresult());
		f.notifyObservers("mancheter united beat liverpool 3-0");
	}
}

위와 같은 옵저버 패턴의 구조에 람다 표현식을 사용해보자

@FunctionalInterface
public interface Observer {
	void notify(String tweet);
}

public class Main {
	public static void main(String[] args) {
		Feed f = new Feed();
		f.registerObserver((String tweet) -> {
			if(tweet != null && tweet.contains("mancherster united")) {
				System.out.println("MU new published !" + tweet);
			}
		});
		f.registerObserver((String tweet) -> {
			if(tweet != null && tweet.contains("mancherster city")) {
				System.out.println("MCY new published !" + tweet);
			}
		});
		f.registerObserver((String tweet) -> {
			if(tweet != null && tweet.contains("liverpool")) {
				System.out.println("LFC new published !" + tweet);
			}
		});
		f.notifyObservers("mancheter united beat liverpool 3-0");
	}
}
  • 다음과 같이 클래스를 구현하지 않고도 다양한 기능을 갖는 옵저버들을 구현할 수 있다.
  • 다만, 옵저버가 상태를 가지고 있다면 기존의 클래스 구현방식이 더 바람직할 수 있다.

4. 연쇄 책임 패턴

의무 체인 패턴은 한 객체가 처리할 작업을 다른 객체로 전달하고, 다른 객체는 또 다른 객체로 전달하는 식의 패턴을 말한다. 따라서 객체의 정보를 유지하는 필드를 포함하는 작업 처리 추상 클래스로 의무 체인 패턴을 구성하게 된다.

public abstract class ProcessingObject<T> {
	protected ProcessingObject<T> successor;
	
	public void setSuccessor(ProcessingObject<T> successor) {
		this.successor = successor;
	}
	
	public T handle(T input) {
		T r = handleWork(input);
		if(successor != null) {
			return successor.handle(r);
		}
		return r;
	}
	
	abstract protected T handleWork(T input);
}
// header text 를 넣는 일을 담당
public class HeaderTextProcessing extends ProcessingObject<String>{
	@Override
	protected String handleWork(String text) {
		return "This is Header !" + text;
	}
}
// 스펠링 체크를 담당
public class SplleCheckerProcessing extends ProcessingObject<String>{
	@Override
	protected String handleWork(String text) {
		return text.replaceAll("APPEL", "APPLE");
	}
}

public class Main {
	public static void main(String[] args) {
		ProcessingObject<String> headerProcessor = new HeaderTextProcessing();
		ProcessingObject<String> spellCheckProcessor = new SplleCheckerProcessing();
		
		headerProcessor.setSuccessor(spellCheckProcessor);
		String result = headerProcessor.handle("I LIKE APPEL");
		
		System.out.println(result);
	}
}
  • 연쇄작업을 위해서 Function<String, String> , UnaryOperation<String> 형식의 인스턴스로 표현할 수 있다.
public class Main {
	public static void main(String[] args) {
		
		UnaryOperator<String> headerProcessor =
				(String text) -> "This is Header !" + text;
		UnaryOperator<String> spellCheckProcessor = 
				(String text) -> text.replaceAll("APPEL", "APPLE");
		
		Function<String, String> pipeLine = headerProcessor.andThen(spellCheckProcessor);
		
		String result = pipeLine.apply("I LIKE APPEL"); 
	}
}

5. 팩토리 패턴

경우에 따라서 다양한 객체를 return 해줘야 하는 경우에 팩토리 패턴을 생각해볼 수 있다.

public class ProductFactory {

	public static Product createProduct(String name) { 
		switch (name) {
		case "loan": return new Loan();
		case "stock": return new Stock();
		case "bond": return new Bond();
		default: throw new IllegalArgumentException("No such product" + name);
		}
	}
	
}

위와 같이 이름에 맞는 은행 상품을 만들어주는 ProductFactory가 있을 때 메서드 참조Map을 활용해서 코드를 재구현할 수 있다.

public class ProductFactoryLambda {

	private final static Map<String, Supplier<Product>> map = new HashMap<>();
	
	static {
		map.put("loan", Loan::new);
		map.put("stock", Stock::new);
		map.put("bond", Bond::new);
	}
	
	public static Product createProduct(String name) {
		Supplier<Product> p = map.get(name);
		if(p != null) return p.get();
		throw new IllegalArgumentException("No such product " + name);
	}
	
}

Supplier 라는 함수형 인터페이스를 활용해서 메서드 참조를 사용했다. 인수로 들어오는 값이 여러 개인 경우에도 함수형 인터페이스를 새롭게 구현함으로써 해결할 수 있다.

@FunctionalInterface
public interface TriFunction<T,U,V,R> {
	R apply(T t, U u, V v);
}

public class ProductFactoryTriFunction {
	private final static Map<String, TriFunction<Integer, String, String, Product>> map = new HashMap<>();
	
	static {
		map.put("SmartPhone", (qty, address, notice) -> new SmartPhone(qty, address, notice));
		map.put("Car", (qty, address, notice) -> new Car(qty, address, notice));
		map.put("Notebook", (qty, address, notice) -> new Notebook(qty, address, notice));
	}
	
	public static Product createProduct(int qty, String address, String notice, String name) {
		TriFunction<Integer, String, String, Product> p = map.get(name);
		if(p != null) return p.apply(qty, address, notice);
		throw new IllegalArgumentException("No such product " + name);
	}
}
  • 메서드 호출자에게 배송 정보(수량, 주소, 배송 요구사항)을 받아 객체를 생성하는 팩토리 패턴이다.

인수가 여러 개가 필요하면 새로운 함수형 인터페이스를 만들어서 사용하면 된다

profile
내 머릿속 지우개

0개의 댓글