[C++ to Java] 9. Generic, Enum, Lambda
여태 다루지 못한 것들에 대해 한번 다뤄보자. 2022-01-26

Generic, Enum, Lambda

안녕하세요? 오늘은 중요하지만, 한 번에 다루기에 애매했던 토픽들에 대해서 배워 보고자 합니다.

Generic

GenericC++ 에서의 Template와 같은 역할을 합니다. 타입 변수를 받아, 이에 맞는 지네릭 클래스를 만들 수 있습니다. 문법은 C++의 것과 상당히 유사 합니다. 클래스를 선언 할 때, 클래스명<타입 변수>를 작성 하면 됩니다.

또한, 지네릭 메서드 또한 만들 수 있습니다. 메서드를 정의할 때, 리턴 타입까지 반환 하면 됩니다.

import java.util.ArrayList;

class MyNewArray<T> {
    private ArrayList<T> array;

    // 초기화 블록
    {
        array = new ArrayList<T>();
    }

    public T get(int i) {
        return this.array.get(i);
    }

    public void add(T item) {
        array.add(item);
    }

    public void print() {
        for (int i = 0; i < this.array.size(); i++) {
            System.out.println(this.array.get(i));
        }
    }
}

public class Main {
    public static void main(String[] args) {
        MyNewArray<Integer> array = new MyNewArray<>();
        array.add(1);
        array.add(2);
        array.add(3);
        array.print();
        System.out.println("0번째 인덱스: " + array.get(0));
    }
}
1
2
3
0번째 인덱스: 1

위와 같이 클래스명<타입변수> 형태로 클래스를 생성 했을 때, 절대 다른 타입변수가 적용된 제네릭 클래스의 객체를 참조할 수 없다는 점입니다. 예시를 들어 보겠습니다. 당연히 SomeClass<Fruit> sc = new SomeClass<Toy>(); 같은 해당 코드는 작동 하지 않을 것 입니다. 아예 다른 클래스니까요, 그 다음으로, Fruit이라는 상위 클래스가 존재하고, Apple이라는 하위 클래스가 존재 한다면, Fruit이 대입 된 지네릭 클래스Apple은 대입 할 수 없습니다. 그렇게 해 놓은 이유는, 의도치 않은 결과가 나오지 않게 끔 하기 위해서 입니다. 클래스명<타입변수>에서, 타입변수는 어느 클래스든 들어 갈 수 있기 때문이에요.

비슷한 이유로, 타입 변수 내에 있는, 가장 기본이 되는 Object 객체에 없는 메서드는 지네릭 클래스 안에서 실행 못하게 하는 것도 같은 이유에요.

SomeClass<Fruit> sc = new SomeClass<Fruit>();  // 가능
SomeClass<Fruit> sc = new SomeClass<Toy>();  // 불가능
SomeClass<Fruit> sc = new SomeClass<Apple>();  // 불가능

지네릭 클래스 내의 다형성

우리가 지네릭 클래스를 사용 하면서, 다형성을 이용 할 수 있게 하고 싶으면, 어떻게 하면 될까요? 지네릭을 제한 시키는 것이 도움이 될 수 있습니다. 한 번 보겠습니다. 만약 클래스명<타입변수 extends 부모클래스>를 이용하여, 부모 클래스 자신 혹은, 부모 클래스자식 클래스만 사용 하도록 만들면 어떨까요? 그렇게 되면, 부모 클래스 내에 있는 메서드들은 지네릭 클래스를 구현 하여 사용할 수 있게 됩니다.

또한, 우리가 지네릭 클래스가 아닌 클래스에서 지네릭 타입에 따라서, 함수가 실행 되게 하기 위해선 어떻게 해야 할까요? 클래스명<타입변수> 처럼 되어 있는 것이 아니기 때문에, 타입변수를 사용 할 수 없을 텐데 말이죠. 그럴 때는 리턴 타입, 혹은 파라미터 타입와일드 카드를 사용하면 됩니다.

와일드 카드는 전체 클래스에 대해서 작동하게 하려면 다음과 같은 세 가지 방법이 있습니다.

  • <? extends T>: T와 그 자손들만 가능
  • <? super T>: T와 그 조상들만 가능
  • <?>: 모든 타입이 가능.
class Fruit {
    public String myName() {return "Fruit";}
}
class Apple extends Fruit {
    @Override
    public String myName() {
        return "Apple";
    }
}
class Banana extends Fruit {
    @Override
    public String myName() {
        return "Banana";
    }
}

class MyClass<T extends Fruit> {
    private T my_fruit;

    public MyClass(T t) {
        my_fruit = t;
    }

    public void print() {
        System.out.println(this.my_fruit.myName());  // Fruit의 메서드를 사용 할 수 있다.
    }
}

public class Main {
    public static void callPrint(MyClass<? extends Fruit> mc) {
        mc.print();
    }

    public static MyClass<?> returnMyClass(MyClass<?> mc) {
        return mc;
    }

    public static void main(String[] args) {
        MyClass<Fruit> f = new MyClass<Fruit>(new Fruit());
        f.print();
        callPrint(f);

        MyClass<Apple> a = new MyClass<Apple>(new Apple());
        a.print();
        callPrint(a);

        MyClass<Banana> b = new MyClass<Banana>(new Banana());
        b.print();
        callPrint(b);
    }
}
Fruit
Fruit
Apple
Apple
Banana
Banana

Enum

C++에서 Enum을 이용한 열거형이 있듯이, Java에도 열거형이 존재 합니다. Java열거형은, C++의 것보다 더 향상된 열거형 입니다. 열거형의 뿐만 아니라, 타입까지 체킹 하기 때문에, 보다 더 논리적인 오류를 줄일 수 있습니다. 또한, 열거형에 멤버변수 혹은 메서드를 추가 할 수 있습니다. 그리하여, 더 객체지향 적인 Enum Class를 만들 수 있습니다.

switch 문을 사용 하려고 할 때는, 열거형 타입을 굳이 입력 하지 않아도 됩니다.

  • String name(): 열거형 객체의 이름을 반환 합니다.
  • int ordinal(): 열거형 객체의 순서를 반환 합니다.
enum Alphabet {A, B, C, D, E, F}
enum Qwerty {
    Q(1), W(3), E(5), R(7), T(9), Y(11);

    // 열거형에 멤버 변수 및 메서드를 적용.
    private final int value;
    Qwerty(int value) {
        this.value = value;
    }
    public int getValue() {return this.value;}
}

public class Main {
    public static void main(String[] args) {
        // System.out.println("Alphabet.A == Qwerty.Q: " + (Alphabet.A == Qwerty.Q)); => 불가능
        System.out.println("Alphabet.A == Qwerty.Q: " + (Alphabet.A == Alphabet.B));  // 가능

        Alphabet alphabet = Alphabet.A;

        switch (alphabet) {
            case A:
                System.out.println("A 입니다!");
                break;
            default:
                System.out.println("A가 아닙니다!");
                break;
        }

        System.out.println(Alphabet.C.name());
        System.out.println(Qwerty.R.ordinal());
        System.out.println(Qwerty.Y.getValue());
    }
}
Alphabet.A == Qwerty.Q: false
A 입니다!
C
3
11

Lambda

람다식(Lambda Expression)은 메서드를 하나의 식(Expression)으로 나타 낸 것 입니다. 우리는 익명 함수를 이용하여, 파라미터를 함수로 받는 함수에 익명 함수를 파라미터로 삽입하여 다른 함수를 따로 구현 할 필요 없이, 이를 구현 할 수 있습니다.

import java.util.*;

public class Main {
    public static void main(String[] args) {
        int[] arr = new int[5];
        Arrays.setAll(arr, (i) -> 2 * (i + 1));
        System.out.println(Arrays.toString(arr));
    }
}
[2, 4, 6, 8, 10]

람다식은 다음과 같은 형식을 따릅니다.

(매개변수) -> { 문장들 }

만약 a, b 중 더 큰 값을 반환하는 max(int a, int b) 함수를 구현 한다고 하면, 어떻게 하면 될까요? 예시는 다음과 같습니다.

(int a, int b) -> {return a > b ? a : b;}
(a, b) -> {return a > b ? a : b;}
(int a, int b) -> a > b ? a : b
(a, b) -> a > b ? a : b  // 타입 추론이 가능 한 경우

여기서, 매개변수가 하나만 존재 한다면, a -> a * a 로 표현 가능 합니다.

Functional Interface

만약, 우리가 람다식을 파라미터로 넣을 수 있다면, 그 람다식은, 특정 클래스에 포함 되어야 한다는 의미를 가집니다. 그렇다면, 람다식은 어떤 클래스 혹은 인터페이스에 들어가 있는 걸까요?

사실, 그것은 우리가 정해주면 되는 것 입니다. 우리는 다음과 같은 조건을 만족하는 인터페이스를 만들어서 제공 하면 됩니다.

  • 어노테이터 (함수 위에 메타정보 삽입)@FunctionalInterface를 넣어 줍니다.
  • 단 하나의 추상 메서드만을 제공 하여야 합니다. public abstract 반환타입 함수명(파라미터)로 말입니다.

다음은 구현 예시 입니다.

@FunctionalInterface
interface MyFunction {
    public abstract int func(int x);
}

public class Main {
    public static void printFunc(int x, MyFunction f) {
        System.out.println(f.func(x));
    }

    public static void main(String[] args) {
        printFunc(2, x -> x * x);
    }
}
4

우리는 x -> x * x 람다식을 구현 후, 파라미터로 넣음으로써 이를 구현 할 수 있었습니다. 사실, 위의 코드에서 x -> x * x 는 다음과 동일 합니다. 우리는 @FunctionalInterface 어노테이션을 통해, 어느 함수로 우리가 만든 익명 함수로 매핑해야 하는 지 알고 있습니다. 당연히, 위의 규칙 대로라면, 유일하게 구현 해야 하는 추상 메서드로 매핑이 될 것 입니다.

new Object() {
    public int func(int x) {
        return x * x;
    }
}

우리는 이런식으로, @FunctionalInterface를 구현 하여, 람다식을 함수의 파라미터로 이용 할 수 있도록 할 수 있었습니다.

java.util.function 패키지

Java에서는 기본적으로 Function Interface를 제공 합니다. 바로 java.util.function에서 말이죠. 일단, 많은 것을 다루지 않고, 기초적인 것만 다루고 넘어 가도록 하겠습니다.

java.lang.Runnable

매개변수도 없고, 반환 값도 없는 인터페이스입니다. Thread 생성 시 자주 사용 합니다.

public class Main {
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {  // 해당 위치에서 사용 됨
            try {
                System.out.println("쓰레드 시작!");
                Thread.sleep(1000);
                System.out.println("쓰레드 끝!");
            } catch (InterruptedException e) {}
        });
        thread.start();
    }
}
쓰레드 시작!
쓰레드 끝!

Supplier

매개변수는 없고, 반환 값 T 만 있는 인터페이스 입니다. T get()을 구현하는 인터페이스 입니다.

import java.util.function.*;

public class Main {
    public static void printFunc(Supplier<?> t) {  // 와일드 카드 사용
        System.out.println(t.get());  // T get()
    }

    public static void main(String[] args) {
        printFunc(() -> 3.14);  // 알아서 변환 됨
    }
}
3.14

Consumer

매개변수로 T를 받고, 반환 값은 없는 인터페이스 입니다. void accept(T t)를 구현하는 인터페이스 입니다.

import java.util.function.*;

public class Main {
    public static void useFunc(int x, Consumer<Integer> func) {
        func.accept(x);
    }

    public static void main(String[] args) {
        useFunc(5, (x) -> {System.out.println(x + "가 입력 되었습니다.");});
    }
}
5가 입력 되었습니다.

Function<T, R>

매개변수로 T를 받고, R을 반환 하는 인터페이스 입니다. R apply(T t)를 구현하는 인터페이스 입니다.

import java.util.function.*;

public class Main {
    public static void main(String[] args) {
        Function<Integer, String> consumer = (x) -> x + "";  // 생성 과정에서 바로 람다식 대입 가능
        System.out.println(consumer.apply(1234));
    }
}
1234

Predicate

매개변수로 T를 받고, boolean을 반환 하는 인터페이스 입니다. boolean test(T t) 를 구현 합니다.

import java.util.function.*;

public class Main {
    public static void main(String[] args) {
        Predicate<int[]> predicate = (int[] array) -> array.length == 0;
        int[] array = {1, 2, 3};
        System.out.println(predicate.test(array));
    }
}
false

기타 인터페이스

기타 인터페이스로 두 개의 파라미터를 받으며 똑같은 기능을 하는 BiConsumer<T, U>, BiPredicate<T, U>, BiFunction<T, U, R> 가 있고, 매개변수 T 한 개를 받아 T로 반환하는 UnaryOperator<T>, 매개변수 T 두 개를 받아 하나의 T로 반환하는 BinaryOperator<T> 가 있습니다. 그 이외에서 많은 인터페이스가 존재 합니다.

import java.util.function.*;

public class Main {
    public static void main(String[] args) {
        BiConsumer<Integer, Float> biConsumer = (Integer i, Float f) -> {System.out.println(i + f);};
        BiPredicate<Integer, Float> biPredicate = (Integer i, Float f) -> i > f;
        BiFunction<Integer, Float, Float> biFunction = (Integer i, Float f) -> (float)i + f;
        UnaryOperator<Integer> unaryOperator = (Integer a) -> a * a;
        BinaryOperator<Integer> binaryOperator = (Integer a, Integer b) -> 2 * a + b;
    }
}

마치며

다음 시간에는 배열, 컬렉션처럼 많은 데이터를 처리 할 수 있는 Stream에 대해서 배워 보도록 하겠습니다.