1. 개요

이제 Java 8이 널리 사용됨에 따라 주요 기능 중 일부에 대한 패턴과 모범 사례가 나타나기 시작했습니다. 이 사용방법(예제)에서는 기능적 인터페이스와 람다 식에 대해 자세히 살펴보겠습니다.

2. 표준 기능 인터페이스 선호

java.util.function 패키지에 수집된 기능 인터페이스 는 람다 식 및 메서드 참조에 대한 대상 유형을 제공하는 데 있어 대부분의 개발자 요구를 충족합니다. 이러한 각 인터페이스는 일반적이고 추상적이므로 거의 모든 람다 식에 쉽게 적용할 수 있습니다. 개발자는 새로운 기능 인터페이스를 만들기 전에 이 패키지를 탐색해야 합니다.

인터페이스  Foo를 고려해 보겠습니다 .

@FunctionalInterface
public interface Foo {
    String method(String string);
}

또한 UseFoo 클래스에 add() 메서드가  있습니다.  이 인터페이스는 매개변수로 사용합니다.

public String add(String string, Foo foo) {
    return foo.method(string);
}

그것을 실행하기 위해 우리는 다음과 같이 작성할 것입니다:

Foo foo = parameter -> parameter + " from lambda";
String result = useFoo.add("Message ", foo);

자세히 살펴보면 Foo 가 하나의 인수를 받아들이고 결과를 생성하는 함수에 불과 하다는 것을 알 수 있습니다. 자바 8은 이미 같은 인터페이스를 제공하는 기능 <T, R> 로부터 java.util.function의 패키지.

이제 인터페이스 Foo를 완전히 제거 하고 코드를 다음과 같이 변경할 수 있습니다 .

public String add(String string, Function<String, String> fn) {
    return fn.apply(string);
}

이를 실행하기 위해 다음과 같이 작성할 수 있습니다.

Function<String, String> fn = 
  parameter -> parameter + " from lambda";
String result = useFoo.add("Message ", fn);

3. @FunctionalInterface 어노테이션 사용

이제 @FunctionalInterface 로 기능 인터페이스에 어노테이션을 달자 . 처음에는 이 어노테이션이 쓸모가 없어 보입니다. 그것이 없어도 인터페이스는 추상 메서드가 하나만 있는 한 기능적으로 처리됩니다.

그러나 여러 인터페이스가 있는 큰 프로젝트를 상상해 봅시다. 모든 것을 수동으로 제어하기는 어렵습니다. 기능적으로 설계된 인터페이스는 다른 추상 메서드/메서드를 추가하여 실수로 변경되어 기능 인터페이스로 사용할 수 없게 만들 수 있습니다.

@FunctionalInterface 어노테이션 을 사용하여 컴파일러는 기능 인터페이스의 사전 정의된 구조를 깨뜨리려는 모든 시도에 대한 응답으로 오류를 트리거합니다. 또한 다른 개발자가 애플리케이션 아키텍처를 더 쉽게 이해할 수 있도록 하는 매우 편리한 도구입니다.

그래서 우리는 이것을 사용할 수 있습니다:

@FunctionalInterface
public interface Foo {
    String method();
}

대신:

public interface Foo {
    String method();
}

4. 함수형 인터페이스에서 기본 메소드를 남용하지 마십시오

기능적 인터페이스에 기본 메소드를 쉽게 추가할 수 있습니다. 이것은 추상 메서드 선언이 하나만 있는 한 기능적 인터페이스 계약에 허용됩니다.

@FunctionalInterface
public interface Foo {
    String method(String string);
    default void defaultMethod() {}
}

기능 인터페이스는 추상 메서드가 동일한 서명을 갖는 경우 다른 기능 인터페이스에 의해 확장될 수 있습니다.

@FunctionalInterface
public interface FooExtended extends Baz, Bar {}
	
@FunctionalInterface
public interface Baz {	
    String method(String string);	
    default String defaultBaz() {}		
}
	
@FunctionalInterface
public interface Bar {	
    String method(String string);	
    default String defaultBar() {}	
}

일반 인터페이스 와 마찬가지로 동일한 기본 메서드로 다른 기능 인터페이스를 확장하는 것은 문제가 될 수 있습니다 .

예를 들어, BarBaz 인터페이스에 defaultCommon() 메서드를 추가해 보겠습니다 .

@FunctionalInterface
public interface Baz {
    String method(String string);
    default String defaultBaz() {}
    default String defaultCommon(){}
}

@FunctionalInterface
public interface Bar {
    String method(String string);
    default String defaultBar() {}
    default String defaultCommon() {}
}

이 경우 컴파일 타임 오류가 발생합니다.

interface FooExtended inherits unrelated defaults for defaultCommon() from types Baz and Bar...

이 문제를 해결하려면 FooExtended 인터페이스 에서 defaultCommon() 메서드를 재정의해야 합니다 . 우리는 이 방법의 사용자 정의 구현을 제공할 수 있습니다. 그러나 부모 인터페이스에서 구현을 재사용할 수도 있습니다 .

@FunctionalInterface
public interface FooExtended extends Baz, Bar {
    @Override
    default String defaultCommon() {
        return Bar.super.defaultCommon();
    }
}

우리가 조심해야 한다는 점에 유의하는 것이 중요합니다. 인터페이스에 너무 많은 기본 메소드를 추가하는 것은 좋은 아키텍처 결정이 아닙니다. 이는 이전 버전과의 호환성을 손상시키지 않고 기존 인터페이스를 업그레이드하는 데 필요한 경우에만 절충안으로 간주되어야 합니다.

5. Lambda 표현식으로 기능 인터페이스 인스턴스화

컴파일러는 내부 클래스를 사용하여 기능적 인터페이스를 인스턴스화할 수 있도록 합니다. 그러나 이것은 매우 장황한 코드로 이어질 수 있습니다. 우리는 람다 식을 사용하는 것을 선호해야 합니다:

Foo foo = parameter -> parameter + " from Foo";

내부 클래스 이상:

Foo fooByIC = new Foo() {
    @Override
    public String method(String string) {
        return string + " from Foo";
    }
};

람다 식 접근 방식은 이전 라이브러리의 적절한 인터페이스에 사용할 수 있습니다. Runnable , Comparator 등과 같은 인터페이스에 사용할 수 있습니다. 시간은 owever, 이것은 우리가 우리의 전체 오래된 코드 기반과 변화 모두를 검토해야한다는 것을 의미하지 않습니다.

6. 함수형 인터페이스를 매개변수로 사용하여 메서드 오버로딩 방지

충돌을 피하기 위해 다른 이름의 메서드를 사용해야 합니다.

public interface Processor {
    String process(Callable<String> c) throws Exception;
    String process(Supplier<String> s);
}

public class ProcessorImpl implements Processor {
    @Override
    public String process(Callable<String> c) throws Exception {
        // implementation details
    }

    @Override
    public String process(Supplier<String> s) {
        // implementation details
    }
}

언뜻 보기에는 합리적으로 보이지만 ProcessorImpl 의 메서드 중 하나를 실행하려는 시도는 다음과 같습니다.

String result = processor.process(() -> "abc");

다음 메시지와 함께 오류로 끝납니다.

reference to process is ambiguous
both method process(java.util.concurrent.Callable<java.lang.String>) 
in com.baeldung.java8.lambda.tips.ProcessorImpl 
and method process(java.util.function.Supplier<java.lang.String>) 
in com.baeldung.java8.lambda.tips.ProcessorImpl match

이 문제를 해결하기 위해 두 가지 옵션이 있습니다. 첫 번째 옵션은 다른 이름의 메서드를 사용하는 것입니다.

String processWithCallable(Callable<String> c) throws Exception;

String processWithSupplier(Supplier<String> s);

두 번째 옵션은 수동으로 캐스팅을 수행하는 것이므로 바람직하지 않습니다.

String result = processor.process((Supplier<String>) () -> "abc");

7. 람다 표현식을 내부 클래스로 취급하지 마십시오

본질적으로 내부 클래스를 람다 식으로 대체한 이전 예에도 불구하고 두 개념은 중요한 면에서 다릅니다.

내부 클래스를 사용하면 새 범위가 생성됩니다. 같은 이름의 새 지역 변수를 인스턴스화하여 둘러싸는 범위에서 지역 변수를 숨길 수 있습니다. 내부 클래스 내에서 this 키워드 를 인스턴스에 대한 참조로 사용할 수도 있습니다 .

그러나 람다 표현식은 범위를 둘러싸고 작동합니다. 우리는 람다 본문 내부의 둘러싸는 범위에서 변수를 숨길 수 없습니다. 이 경우 키워드 this 는 둘러싸는 인스턴스에 대한 참조입니다.

예를 들어 UseFoo 클래스 에는 인스턴스 변수 값이 있습니다.

private String value = "Enclosing scope value";

그런 다음 이 클래스의 일부 메서드에 다음 코드를 배치하고 이 메서드를 실행합니다.

public String scopeExperiment() {
    Foo fooIC = new Foo() {
        String value = "Inner class value";

        @Override
        public String method(String string) {
            return this.value;
        }
    };
    String resultIC = fooIC.method("");

    Foo fooLambda = parameter -> {
        String value = "Lambda value";
        return this.value;
    };
    String resultLambda = fooLambda.method("");

    return "Results: resultIC = " + resultIC + 
      ", resultLambda = " + resultLambda;
}

scopeExperiment() 메서드를 실행하면 다음과 같은 결과를 얻을 수 있습니다. 결과: resultIC = 내부 클래스 값, resultLambda = 범위 값 포함

보시다시피 IC에서 this.value호출 하면 해당 인스턴스에서 로컬 변수에 액세스할 수 있습니다. 람다의 경우, this.value 호출은 우리가 변수에 액세스 할 수 있습니다 에 정의되어 UseFoo의 가 아니라 변수, 클래스 람다의 몸 안에 정의했다.

8. 람다 표현식을 짧고 이해하기 쉽게 유지

가능하면 큰 코드 블록 대신 한 줄 구성을 사용해야 합니다. 람다는 서술이 아니라 표현 이어야 함을 기억하십시오 . 간결한 구문에도 불구하고 람다는 제공하는 기능을 구체적으로 표현해야 합니다.

성능이 크게 바뀌지 않을 것이기 때문에 이것은 주로 스타일에 관한 조언입니다. 그러나 일반적으로 그러한 코드를 이해하고 작업하는 것이 훨씬 쉽습니다.

이것은 여러 가지 방법으로 달성할 수 있습니다. 자세히 살펴보겠습니다.

8.1. Lambda 본문에서 코드 블록 피하기

이상적인 상황에서 람다는 한 줄의 코드로 작성되어야 합니다. 이 접근 방식에서 람다는 어떤 데이터로 어떤 작업을 실행해야 하는지 선언하는 자체 설명 구조입니다(매개변수가 있는 람다의 경우).

코드 블록이 크면 람다의 기능이 즉시 명확하지 않습니다.

이를 염두에 두고 다음을 수행하십시오.

Foo foo = parameter -> buildString(parameter);
private String buildString(String parameter) {
    String result = "Something " + parameter;
    //many lines of code
    return result;
}

대신에:

Foo foo = parameter -> { String result = "Something " + parameter; 
    //many lines of code 
    return result; 
};

이 "한 줄 람다" 규칙을 도그마로 사용해서는 안 된다는 점에 유의하는 것이 중요합니다 . 람다의 정의에 두세 줄이면 해당 코드를 다른 메서드로 추출하는 것은 가치가 없을 수 있습니다.

8.2. 매개변수 유형 지정 피하기

대부분의 경우 컴파일러는 형식 유추 를 통해 람다 매개 변수의 형식을 확인할 수 있습니다. 따라서 매개변수에 유형을 추가하는 것은 선택 사항이며 생략할 수 있습니다.

우리는 할 수있어:

(a, b) -> a.toLowerCase() + b.toLowerCase();

대신:

(String a, String b) -> a.toLowerCase() + b.toLowerCase();

8.3. 단일 매개변수 주위의 괄호 피하기

Lambda 구문은 둘 이상의 매개변수를 괄호로 묶거나 매개변수가 전혀 없는 경우에만 필요합니다. 그렇기 때문에 코드를 조금 더 짧게 만들고 매개변수가 하나만 있는 경우 괄호를 제외하는 것이 안전합니다.

그래서 우리는 이것을 할 수 있습니다:

a -> a.toLowerCase();

대신:

(a) -> a.toLowerCase();

8.4. 반품 문 및 중괄호 피하기

중괄호return 문은 한 줄 람다 본문에서 선택 사항입니다. 이는 명확성과 간결성을 위해 생략할 수 있음을 의미합니다.

우리는 할 수있어:

a -> a.toLowerCase();

대신:

a -> {return a.toLowerCase()};

8.5. 메서드 참조 사용

이전 예제에서도 람다 식은 이미 다른 곳에서 구현된 메서드를 호출하는 경우가 많습니다. 이 상황에서 다른 Java 8 기능인 메소드 참조 를 사용하는 것이 매우 유용합니다 .

람다 표현식은 다음과 같습니다.

a -> a.toLowerCase();

다음과 같이 대체할 수 있습니다.

String::toLowerCase;

항상 더 짧은 것은 아니지만 코드를 더 읽기 쉽게 만듭니다.

9. "효과적으로 최종적인" 변수 사용

람다 표현식 내부가 아닌 최종 변수를 액세스하는 것은 컴파일 타임 오류가 발생합니다, 그 유타는 우리가 모든 대상 변수 표시해야합니다 것을 의미하지 않는다 결승전.

" 효과적으로 최종적인 " 개념 에 따르면 컴파일러는  한 번만 할당되는 한 모든 변수를 최종적인 것으로 취급합니다 .

컴파일러가 상태를 제어하고 변경 시도 직후 컴파일 타임 오류를 트리거하므로 람다 내에서 이러한 변수를 사용하는 것이 안전합니다.

예를 들어 다음 코드는 컴파일되지 않습니다.

public void method() {
    String localVariable = "Local";
    Foo foo = parameter -> {
        String localVariable = parameter;
        return localVariable;
    };
}

컴파일러는 다음을 알려줄 것입니다.

Variable 'localVariable' is already defined in the scope.

이 접근 방식은 람다 실행을 스레드로부터 안전하게 만드는 프로세스를 단순화해야 합니다.

10. 돌연변이로부터 개체 변수 보호

람다의 주요 목적 중 하나는 병렬 컴퓨팅에서 사용하는 것입니다. 즉, 스레드 안전성과 관련하여 람다가 매우 유용합니다.

"효과적으로 최종적인" 패러다임은 여기에서 많은 도움이 되지만 모든 경우에 그런 것은 아닙니다. Lambda는 범위를 둘러싸고 있는 객체의 값을 변경할 수 없습니다. 그러나 가변 개체 변수의 경우 람다 식 내에서 상태가 변경될 수 있습니다.

다음 코드를 고려하십시오.

int[] total = new int[1];
Runnable r = () -> total[0]++;
r.run();

이 코드는 total 변수가 "효과적으로 최종적인" 상태로 유지 되기 때문에 합법적 이지만 참조하는 개체가 람다 실행 후 동일한 상태를 갖게 될까요? 아니요!

예상치 못한 변형을 일으킬 수 있는 코드를 피하기 위해 이 예제를 기억해두세요.

11. 결론

이 기사에서 우리는 Java 8의 람다 표현식과 기능적 인터페이스의 몇 가지 모범 사례와 함정을 살펴보았습니다. 이러한 새로운 기능의 유용성과 강력함에도 불구하고 이는 도구일 뿐입니다. 모든 개발자는 사용하는 동안 주의를 기울여야 합니다.

예제 의 전체 소스 코드이 GitHub 프로젝트 에서 사용할 수 있습니다 . Maven 및 Eclipse 프로젝트이므로 그대로 가져와서 사용할 수 있습니다.

Junit footer banner