juuuding

[JAVA] 람다식의 개념과 활용 본문

Spring/JAVA 기본 개념

[JAVA] 람다식의 개념과 활용

jiuuu 2023. 3. 27. 00:37

*람다식은 객체 지향 언어인 자바에서 함수형 프로그래밍 방식을 쓸 수 있게 해주는 문법 요소다. 람다식은 최근 자바 API에서 제공하는 여러 메서드의 매개변수로 자주 사용되는 중이니 꼭 익히는 것이 좋다.

 

 

 람다식 

 

 람다식은 함수형 프로그래밍 기법을 지원하는 자바의 문법 요소다. 람다식을 알기에 앞서 함수와 메서드의 정의를 정확히 알아두고 가는 것이 좋다. 

 

  • 함수: 기능 또는 동작을 정의한 일련의 명령 모음
void abc() {
    //기능 및 동작
}
  • 메서드: 클래스 또는 인터페이스 내에 정의된 함수
class A {
     void abc() {
     //기능 및 동작
    }
}

 객체 지향형 프로그래밍에서 함수는 항상 클래스 내부에 메서드로 존재해야 하고, 메서드를 사용하기 위해서는 클래스의 객체를 먼저 생성한 후에 메서드를 호출해야 한다. 모든 메서드가 특정 클래스 안에 위치하고 있으므로 기능을 사용하기 위해서는 어쩔 수 없는 과정이다. 이와 달리 함수형 프로그래밍과 같이 독립적으로 함수를 만들고 모든 클래스에서 공통적으로 사용할 수 있다면 모든 클래스에서 공통 기능을 정의하지 않아도 되고, 독립적으로 정의한 함수를 호출만 하면 공통 기능을 사용할 수 있으므로 훨씬 효율적일 것이다. 이를 위해 나온 방법이 '람다식'이다. 자바는 인터페이스 문법을 활용하여 람다식을 표현한다. 단 하나의 추상 메서드만 포함하는 인터페이스를 함수형 인터페이스라고 하고, 이 함수형 인터페이스의 호출 및 기능을 구현하는 방벙을 새롭게 정리한 문법이 람다식인 것이다. 즉, 람다식은 기존의 객체 지향 프로그램 체계 안에서 함수형 프로그래밍을 가능하게 하는 기법이라 생각할 수 있다.

 

 

1. 객체 지향 구조 내에서 람다식 적용 과정

 자바의 객체 지향 구조 내에서 람다식이 적용되는 과정을 살펴보자. 다음은 함수형 인터페이스를 상속받아 추상 메서드를 구현한 객체를 생성한 후 메서드를 호출하는 과정을 기존 방법과 람다식을 이용한 방법을 비교해 나타내고 있다.

interface A {
    void abc();
}
class B implements A {
    @Override
    public void abc() {
        System.out.println("메서드 내용1");
    }
}

public class OOPvsFP {
    public static void main(String[] args){
        // 1. 객체 지향 프로그래밍 문법 1
        A a1 = new B();
        a1.abc();
        // 2. 객체 지향 프로그래밍 문법 2(익명 이너 클래스 사용)
        A a2 = new A(){
            @Override
            public void abc() {
                System.out.println("메서드 내용2");
            }
        };
        a2.abc();
        // 3. 함수형 프로그래밍 문법(람다식)
        A a3 = () -> {System.out.println("메서드 내용3");};
        a3.abc();

    }

}

람다식은 익명 이너 클래스 정의 내부에 있는 메서드명을 포함해 이전 부분을 삭제한 형태이다. 즉, 람다식은 익명 이너 클래스의 축약된 형태라고 볼 수 있다. 이제 앞서 말한 것처럼 내부에 단 하나의 추상 메서드만을 가진 함수형 인터페이스만 람다식으로 표현할 수 있는 이유를 알 수 있다. 람다식은 내부 메서드명을 생략하므로 만약 구현해야할 추상 메서드가 2개 이상이라면 어떤 메서드를 구현할 것인지 알 수 없기 때문이다. 

 

 

2. 람다식의 기본 문법 및 약식 표현

 

[람다식의 기본 문법]

 구현된 추상 메서드를 람다식으로 표현할 때는 메서드 명 이후의 소괄호와 중괄호만을 차례대로 포함하고, 이들 사이에는 람다식 기호인 화살표가 들어간다. 즉, '(소괄호) -> {중괄호}' 의 형태만 꼭 기억하면 된다. 여기서 소괄호는 입력매개변수, 중괄호는 메서드의 내용을 나타낸다.

//1
void method1(){
    System.out.println(3);
}

() -> {System.out.println(3);}

//2
void method2(int a){
    System.out.println(a);
}
(int a) -> {System.out.println(a);}

//3
int method3() {
    return 5;
}
() -> {return 5;}

//4
double method4(int a, double b) {
    return a+b;
}
(int a, double b) -> {return a+b;}

 

[람다식의 약식 표현]

람다식은 익명 이너 클래스를 활용한 객체 생성 방법의 축약 형태이다. 이런 람다식은 특정 조건에서 더 축약해서 표현할 수 있다. 

 1) 중괄호 안의 실행문이 1개일 때 중괄호 생략 가능

A a1 = () -> {System.out.println("테스트");};
A a2 = () -> System.out.println("테스트");

 2) 입력매개변수의 자료형 생략 가능, 매개변수가 1개일 때 ()소괄호 생략 가능. 소괄호 생략 시 자료형 반드시 생략

A a1 = (int a) -> {...};
A a2 = (a) - > {...};
A a3 = a -> {...};

//소괄호가 생략될 때는 반드시 매개변수 타입을 생략해야한다
A a4 = int a -> {...};	 // 불가능

 3) 메서드가 return문 하나만으로 이뤄져 있을 때 return 생략 가능. return 생략 시 중괄호 반드시 생략

A a1 = (int a, int b) -> {return a+b;};
A a2 = (int a, int b) -> a+b;

//return 생략 시 중괄호 반드시 생략
A a3 = (int a, int b) -> {a+b;};   //불가능

 

 

 

 람다식의 활용

 

 람다식은 익명 이너 클래스 내부 구현 메서드의 약식 표현뿐 아니라 메서드 참조생성자 참조에도 사용된다. 구현 메서드의 약식 표현은 함수형 인터페이스의 추상 메서드를 직접 구현했을 때, 메서드 참조는 이미 있는 메서드로 대체했을 때를 말한다. 생성자 참조는 구현 메서드의 내용이 객체 생성 코드만으로 고정돼 있을 때다. 이렇게 람다식은 어떤 방식으로 구현하느냐에 따라 3가지 형태를 띈다.

 

 

1. 구현 메서드의 약식 표현

 이 활용법은 앞의 람다식의 약식 표현과 동일한 내용이다. 익명 이너 클래스 내부의 구현 메서드를 단순히 축약해 표현한 형태라는 것이다. 

 

 

2. 메서드 참조

 이 활용법은 이미 구현이 완료된 메서드를 참조하는 것이다. 메서드를 참조하는 방식은 인스턴스 메서드를 참조할 때와 정적 메서드를 참조할 때로 나눠지며, 인스턴스 메서드의 참조는 다시 2가지로 나뉜다.

 

[정의돼 있는 인스턴스 메서드 참조]

 인스턴스 메서드를 참조하기 위해서는 먼저 인스턴스 메서드가 사용할 수 있는 상태가 돼야 하므로 객체를 먼저 생성해야한다. 이후 '클래스 객체::인스턴스 메서드명'로 인스턴스 메서드를 참조하면 된다. 

interface A {
    void abc();
}
class B {
    public void bcd() {
        System.out.println("메서드");
    }
}

public class RefOfInterfaceMethod_Type1_1 {
    public static void main(String[] args){
        //1. 익명 이너 클래스
        A a1 = new A(){
            @Override
            public void abc() {
                B b = new B();
                b.bcd();
            }
        };
        //2. 람다식으로 표현
        A a2 = () -> {
            B b = new B();
            b.bcd();
        };
        //3. 정의된 인스턴스 메서드 참조
        B b = new B();
        A a3 = b::bcd;
        
        a1.abc();
        a2.abc();
        a3.abc();
        
        //메서드
        //메서드
        //메서드
        
    }

}

 여기서 1번과 2번을 살펴보면 구현한 abc() 메서드에서는 B 클래스 객체를 생성해 인스턴스 메서드인 bcd()를 호출했다. 즉, abc() 메서드를 호출하면 B 객체 멤버인 bcd()를 호출하는 것이므로 abc()와 bcd()는 동일한 셈이다. 그렇다면 굳이 이렇게 적지 않고 이미 완성된 인스턴스인 bcd()가 abc()와 동일하다는 사실만 알려주면 된다. 이것이 바로 람다식의 인스턴스 메서드 참조이며 3번과 같이 표현할 수 있다. 3번을 작성하면 'A 인스턴스 내부의 abc() 메서드는 참조 변수 b 객체 내부의 인스턴스 메서드 bcd()와 동일하다.'는 의미가 된다. 여기서 주의할 점은 abc()가 bcd()를 참조하기 위해서는 리턴 타입과 매개변수의 타입이 동일해야한다.

 

위의 내용을 활용하여 다음과 같은 메서드를 참조해볼 수 있다.

interface A {
    void abc(int k);
}

public class RefOfInterfaceMethod_Type1_2 {
    public static void main(String[] args){
        //1. 익명 이너 클래스
        A a1 = new A(){
            @Override
            public void abc(int k) {
               System.out.println(k);
            }
        };
        //2. 람다식으로 표현
        A a2 = (int k) -> {
            System.out.println(k);
        };
        //3. 정의된 인스턴스 메서드 참조
        A a3 = System.out::println;

        a1.abc(3);
        a2.abc(3);
        a3.abc(3);

    }

}

위의 코드에서는 abc()는 System.out.println()와 항상 동일한 동작을 수행한다. 또한 System.out 자체가 객체이므로 객체를 따로 생성할 필요 없이 3번과 같이 간단하게 작성할 수 있다. 결론적으로 '인터페이스 A의 추상 메서드인 abc()는 System.out.println을 참조하라'는 의미가 된다. 따라서 abc()를 호출하면 System.out.println의 기능이 작동한다.

 

[정의돼 있는 정적 메서드 참조]

 이미 정의돼 있는 정적 메서드를 참조하는 방법이다. 정적 메서드는 객체 생성 없이 클래스명으로 바로 사용할 수 있기 때문에 객체의 생성 없이 클래스명을 바로 사용했다고 생각하면 된다. 이 또한 메서드의 참조를 위해서는 리턴 타입과 입력매개변수 타입이 동일해야한다. '클래스 ::인스턴스 메서드명'

interface A {
    void abc();
}
class B {
    static void bcd(){
        System.out.println("메서드");
    }
}

public class RefOfStaticMethod {
    public static void main(String[] args){
        //1. 익명 이너 클래스
        A a1 = new A(){
            @Override
            public void abc() {
                B.bcd();   
            }
        };
        //2. 람다식으로 표현
        A a2 = () -> {B.bcd();};
        //3. 정의된 인스턴스 메서드 참조
        A a3 = B::bcd;

        a1.abc();
        a2.abc();
        a3.abc();

    }

}

 

[첫 번째 매개변수로 전달된 객체의 인스턴스 메서드 참조]

 이 방법은 첫 번째 인스턴스 메서드 참조 방법의 변형된 형태이다. 추상 메서드의 구현 과정에서 첫 번째 매개변수로 인그턴스 메서드를 포함하고 있는 객체를 함께 전달하고, 이후 전달된 객체의 인스턴스 메서드를 참조할 때다. 이때 람다식의 표현 방법은 '클래스::인스턴스 메서드명'을 사용한다. 객체가 첫 번째 매개변수로 전달되므로 객체 생성 과정은 따로 필요 없다.

 이처럼 전달된 객체의 메서드를 참조할 때는 추상 메서드의 첫 번째 매개변수로 객체가 들어가기 때문에 참조 메서드보다 매개변수가 1개 많다. 

interface A {
    void abc(B b, int k);
}
class B {
    void bcd(int k) {
        System.out.println(k);
    }
}

public class RefOfInstanceMethod_Type2_1 {
    public static void main(String[] args){
        //1. 익명 이너 클래스
        A a1 = new A(){
            @Override
            public void abc(B b, int k) {
                b.bcd(k);
            }
        };
        //2. 람다식으로 표현
        A a2 = (B b, int k) -> {b.bcd(k);};
        //3. 직접 정의한 인스턴스 메서드 참조
        A a3 = B::bcd;

        a1.abc(new B(),3);
        a2.abc(new B(),3);
        a3.abc(new B(),3);

    }

}

 

이와 같이 직접 정의한 메서드를 참조해봤으므로 이번에는 자바에서 제공하는 클래스의 메서드를 같은 방법으로 참조해보자. 입력매개변수로 String 객체를 넘겨 length()메서드를 호출할 것이다.

interface A {
    int abc(String str);
}

public class RefOfInstanceMethod_Type2_2 {
    public static void main(String[] args){
        //1. 익명 이너 클래스
        A a1 = new A(){
            @Override
            public int abc(String str) {
                return str.length();
            }
        };
        //2. 람다식으로 표현
        A a2 = (String str) -> str.length();
        //3. 자바에서 제공하는 인스턴스 메서드 참조
        A a3 = String::length;

        System.out.println(a1.abc("안녕"));
        System.out.println(a1.abc("안녕"));
        System.out.println(a1.abc("안녕"));
    }

}

여기서는 추상 메서드 abc()가 String 클래스의 length() 메서드를 호출하고 있다. 즉 abc()는 String 클래스의 length() 메서드와 동일한 기능을 한다.

 

 

3. 생성자 참조

 생성자 참조에서 참조하는 생성자는 크게 배열 객체 생성자클래스 객체 생성자로 나뉜다.

 

[배열 생성자 참조]

 함수형 인터페이스에 포함된 추상 메서드가 배열의 크기를 입력매개변수로 하며, 특정 배열 타입을 리턴한다면 구현 메서드 내부에는 반드시 new자료형[]이 포함된다. 이때 추상 메서드의 구현 메서드가 new 자료형[]과 같이 배열 객체의 생성 기능만을 수행할 때는 람다식의 배열 생성자 참조 방법을 사용할 수 있다. 여기서 'abc(len)을 호출하면 len의 크기를 갖는 new int[len]을 실행한 후 객체를 리턴하라'는 의미가 된다.

interface A {
    int[] abc(int len);
}

public class RefOfArrayConstructor {
    public static void main(String[] args){
        //1. 익명 이너 클래스
        A a1 = new A(){
            @Override
            public int[] abc(int len) {
                return new int[len];
            }
        };
        //2. 람다식으로 표현
        A a2 = (int len) -> new int[len];
        //3. 자바에서 제공하는 인스턴스 메서드 참조
        A a3 = int[]::new;

        int[] array1 = a1.abc(3);
        System.out.println(array1.length);
        int[] array2 = a2.abc(3);
        System.out.println(array2.length);
        int[] array3 = a3.abc(3);
        System.out.println(array3.length);
        
        // 3 3 3
    }

}

 

[클래스 생성자 참조]

 인터페이스의 추상 메서드가 클래스 타입의 객체를 리턴할 때도 생성자 참조를 사용할 수 있다. 배열과 완전히 동일한 개념으로 추상 메서드를 구현할 때 new 생성자()와 같이 객체만을 생성해 리턴하면 클래스 생성자를 참조할 수 있다. 이는 'a.abc() 메서드를 호출하면 new B()를 실행해 객체를 생성하라'는 의미다.

interface A {
    B abc();
}
class B{
    B(){
        System.out.println("첫 번째 생성자");
    }
    B(int k){
        System.out.println("두 번째 생성자");
    }
}

public class RefOfClassConstructor_1 {
    public static void main(String[] args){
        //1. 익명 이너 클래스
        A a1 = new A(){
            @Override
            public B abc() {
                return new B();
            }
        };
        //2. 람다식으로 표현
        A a2 = () -> new B();
        //3. 자바에서 제공하는 인스턴스 메서드 참조
        A a3 = B::new;

        a1.abc();
        a2.abc();
        a3.abc();

        // 첫 번째 생성자
        // 첫 번째 생성자
        // 첫 번째 생성자
        
    }

}

 

여기까지의 클래스 생성자 참조는 배열의 참조와 완전히 동일하다. 하지만 클래스 생성자 참조에서는 생성자가 여러 개일 수도 있다는 것을 고려해야한다. 클래스 B가 여러 생성자를 갖고 있을 때 A a = B::new와 같이 표현하면 어떤 생성자가 호출되는지가 문제인 것이다. 이럴 때는 인터페이스 A에 포함된 추상 메서드의 매개변수에 따라 결정된다. 아래의 코드에서는 위의 코드와 똑같이 A a = B::new이지만, 'abc(k)를 호출하면 new B(K)를 실행하라'는 의미가 된다.

interface A {
    B abc(int k);
}
class B{
    B(){
        System.out.println("첫 번째 생성자");
    }
    B(int k){
        System.out.println("두 번째 생성자");
    }
}

public class RefOfClassConstructor_1 {
    public static void main(String[] args){
        //1. 익명 이너 클래스
        A a1 = new A(){
            @Override
            public B abc(int k) {
                return new B(3);
            }
        };
        //2. 람다식으로 표현
        A a2 = (int k) -> new B(3);
        //3. 자바에서 제공하는 인스턴스 메서드 참조
        A a3 = B::new;

        a1.abc(3);
        a2.abc(3);
        a3.abc(3);

        // 두 번째 생성자
        // 두 번째 생성자
        // 두 번째 생성자

    }

}