본문 바로가기
Programing/Java

[자바8 람다의 힘] 2장 컬렉션의 사용

by Tomining 2015. 3. 26.
이 글은 "자바 8 람다의 힘" 책을 정리한 글입니다.

Chapter 2. 컬렉션의 사용


Java8에서 컬렉션을 어떻게 사용하는지 알아보기 전에 Java에서 Collection은 무엇인지부터 알아보자.

다음은 Java Collection Framework의 기본 상속 구조이다.





Java 프로그래밍을 하다보면 크게 3가지 객체타입을 마주하게 된다.

 - List
    Element 들의 순서가 있으며, 중복을 허용한다.

 - Set

    Element 들의 순서는 없고, 중복이 허용되지 않는다.

 - Map

    List와 Set이 집합적 개념이라면, Map은 검색적 개념이 가미된 Interface이다.
    key와 value로 구성되며, key를 통해 value에 접근할 수 있다.
    주로 사용되는 HashMap의 경우 Java 버전에 따라 동장 원리가 조금씩 차이가 있다.
    (상세하게 알고 싶다면 아래 참고 사이트를 확인해라)




Java8에서는 Collection을 iteration하고, transform(변형)하고, Element를 추출하거나, 그 Element들을 연결하는 여러가지 방법을 제공한다. 하나씩 살펴보자.

1. Iteration

예를 들어, 아래와 같이 friends라는 친구들의 이름을 Element로 가지고 있는 List가 있다고 하자

private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

친구들의 이름을 출력해야 한다면, 아마도 다들 아래와 같이 코딩을 하게 될 것이다.

public class Iteration {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

    public static void main(String[] args) {
        for (String name : friends) {
            System.out.println(name);
        }
    }
}

java 8에서 제공하는 forEach와 Consummer<T> 인터페이스를 이용하면 아래와 같이 된다.

public class Iteration {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

    public static void main(String[] args) {
        friends.forEach(new Consumer<String>() {
            public void accept(final String name) {
                System.out.println(name);
            }
        });
    }
}


※ Consummer<T> 인터페이스는 T 타입의 객체를 소비하는 인터페이스이다. accept() 라는 하나의 method를 가지고 있다.


여기에 Lambda 표현식을 추가해 보자.

public class Iteration {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

    public static void main(String[] args) {
        friends.forEach(name -> System.out.println(name));

        friends.forEach(System.out::println);
    }
}

위 코드들은 모두 동일한 결과가 나온다.
조금 흥미로은 점은 마지막 코드 예제인데.

System.out::println 으로 method reference라고 한다. 추후 Steam API를 공부하게 되면 자연스럽게 접할 기회가 많으니, 여기서는 이런 방법도 있다 정도로 하고 넘어가면 좋을 것 같다.


2. Transform(변형)

친구들의 이름을 모두 대문자로 변형하는 코드를 작성해보자.

public class Transform {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

    public static void main(String[] args) {
        final List<String> upperCaseNames = new ArrayList<String>();

        for (String name : friends) {
            upperCaseNames.add(name.toUpperCase());
        }

    
    System.out.println(upperCaseNames);
    }
}

뭔가 장황한 느낌이다. 이 코드를 개선해보자.

 - foreach() 적용 및 Lambda 적용

 - Stream API 적용

 - Method Reference 적용


public class Transform {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

    public static void main(String[] args) {
        final List<String> upperCaseNames = new ArrayList<String>();

        friends.forEach(name -> upperCaseNames.add(name.toUpperCase()));

    
    System.out.println(upperCaseNames);
    }
}
public class Transform {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

    public static void main(String[] args) {
        friends.stream()
            .map(name -> name.toUpperCase())
            .forEach(name -> System.out.print(name + " "));
    }
}
public class Transform {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

    public static void main(String[] args) {
        friends.stream()
            .map(String::toUpperCase)
            .forEach(System.out::println);
    }
}

Method Reference 는 언제 사용하는 것일까?

람다 표현식을 사용할 때 파라미터를 전달하지 않는 경우라면 사용할 수 있다.


3. Element 찾기

친구들 이름 중에 N으로 시작하는 친구를 찾아보자.

public class PickElements {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

    public static void main(String[] args) {
        final List<String> startsWithN = new ArrayList<String>();
        for (String name : friends) {
            if (name.startsWith("N")) {
                startsWithN.add(name);
            }
        }

    
    System.out.println(startsWithN);
    }
}

아마도 대부분의 개발자들이 위처럼 코드를 작성할지도 모른다. 간단한 기능임에도 다소 장황하게 보인다.

filter() 함수를 통해 코드를 좀 더 직관적이고 간단하게 작성할 수 있다.

public class PickElements {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

    public static void main(String[] args) {
        final List<String> startsWithN = friends.stream()
                                                            .filter(name -> name.startsWith("N"))
                                                            .collect(Collectors.toList());
        System.out.println(startsWithN);
    }
}

좀 더 직관적이고 간단하게 코딩할 수 있다. 하지만 약간의 센스있는 개발자라면 개선할 점이 있다는 부분을 발견할 수 있을 것이다.

만약 친구리스트가 아니라 에디터리스트 중에서도 N으로 시작하는 에디터를 출력한다고 해보자.

public class PickElements {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");
    private final static List<String> editors = Arrays.asList("Brian", "Jackie", "John", "Mike");

    public static void main(String[] args) {
        final List<String> friendsStartsWithN = friends.stream()
                                                                    .filter(name -> name.startsWith("N"))
                                                                    .collect(Collectors.toList());
        System.out.println(friendsStartsWithN);

        final List<String> editorsStartsWithN = editors.stream()
                                                                    .filter(name -> name.startsWith("N"))
                                                                    .collect(Collectors.toList());
        System.out.println(editorsStartsWithN);
    }
}

중복코드가 발생했다. 이런 경우 아래 코드가 중복 코드이다.
name -> name.startsWith("N")

filter() 함수는 java.util.function.Predicate 함수형 인터페이스를 받는다. 이를 이용하여 일부 중복을 제거할 수 있다.

public class PickElements {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");
    private final static List<String> editors = Arrays.asList("Brian", "Jackie", "John", "Mike");

    private final static Predicate<String> startsWithN = name -> name.startsWith("N");

    public static void main(String[] args) {
        final List<String> friendsStartsWithN = friends.stream()
                                                                    .filter(startsWithN)
                                                                    .collect(Collectors.toList());
        System.out.println(friendsStartsWithN);

        final List<String> editorsStartsWithN = editors.stream()
                                                                    .filter(startsWithN)
                                                                    .collect(Collectors.toList());
        System.out.println(editorsStartsWithN);
    }
}

아직 더 개선할 점이 많지만 여기서는 Predicate<T> 함수형 인터페이스를 어떻게 활용할 수 있는지에 대해서만 언급하고 넘어가기로 한다.

다른 경우를 생각해보자. N으로 시작하는 친구목록과 B로 시작하는 친구 목록을 각각 출력해보자.

public class PickElements {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

    private final static Predicate<String> startsWithN = name -> name.startsWith("N");
    private final static Predicate<String> startsWithB = name -> name.startsWith("B");

    public static void main(String[] args) {
        final List<String> friendsStartsWithN = friends.stream()
                                                                    .filter(startsWithN)
                                                                    .collect(Collectors.toList());
        System.out.println(friendsStartsWithN);

        final List<String> friendsStartsWithB = friends.stream()
                                                                    .filter(startsWithB)
                                                                    .collect(Collectors.toList());
        System.out.println(friendsStartsWithB);
    }
}

여기서 보면 Predicate<T> 형 변수가 N과 B 문자열 차이만 있을 뿐 중복이다.
중복을 제거할 방법이 있을까?

public class PickElements {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

    public static void main(String[] args) {
        final List<String> friendsStartsWithN = friends.stream()
                                                                    .filter(checkIfStartWith("N"))
                                                                    .collect(Collectors.toList());
        System.out.println(friendsStartsWithN);

        final List<String> friendsStartsWithB = friends.stream()
                                                                    .filter(checkIfStartWith("B"))
                                                                    .collect(Collectors.toList());
        System.out.println(friendsStartsWithB);
    }

    public static Predicate<String> checkIfStartWith(final String letter) {
        return name -> name.startsWith(letter);
    }
}
public static Predicate<String> checkIfStartWith(final String letter) {
        return name -> name.startsWith(letter);
}
public class PickElements {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

    private final static Function<String, Predicate<String>> startsWithLetter =
        letter -> {
            return name -> name.startsWith(letter);
        };

    public static void main(String[] args) {
        final List<String> friendsStartsWithN = friends.stream()
                                                                    .filter(startsWithLetter.apply("N"))
                                                                    .collect(Collectors.toList());
        System.out.println(friendsStartsWithN);

        final List<String> friendsStartsWithB = friends.stream()
                                                                    .filter(startsWithLetter.apply("B"))
                                                                    .collect(Collectors.toList());
        System.out.println(friendsStartsWithB);
    }
}

Predicate<String> 을 생성하면 된다.
가만히 보면 checkIfStartWith 함수의 파라미터인 letter가 람다표현식 내에서 사용되었다.
letter 변수는 참다 표현식 범위에 있지 않음에도 이 코드는 정상적으로 수행된다.
이를 Lexical Scope 라고 한다.
     Lexical Scope란? 사용한 하나의 컨텍스트에서 제공한 값을 캐시해 두었다가 나중에 다른 컨텍스트에서 사용할 수 있는 기술이다.
     이 때 그 값은 변경이 불가능하며, final 로 선언되거나 effectively final 과 같은 의미를 가지는 변수이다. 만약 값을 변경하려고 한다면 컴파일 오류가 발생한다.

Function<T, R> 함수형 인터페이스도 활용할 수 있다.
Generic 표현에서도 알 수 있듯이, T 타입의 파라미터를 받아서 R 타입을 반환한다. 위 예제에서는 letter를 받아서 Predicate<String> 을 반환하고 있다.

위 예제에서 코드를 더 간단하게 작성이 가능한데, 

private final static Function<String, Predicate<String>> startsWithLetter =
    letter -> {
        return name -> name.startsWith(letter);
    };
private final static Function<String, Predicate<String>> startsWithLetter =
    letter -> name -> name.startsWith(letter);

Predicate<T> 와 Function<T, R> 두 개의 함수형 인터페이스에 대해서도 함께 알아보았다. 나중에 상당히 많이 활용되는 인터페이스이니 꼭 알아두길 권장한다.

3. Element 찾기

보통 Collection 에서  여러 Element 를 찾는 것 보다 하나의 Element 를 찾는 것이 간단하다는 것은 당연하다.
그러나 습관적으로 코딩하다 보면 복잡도(Complexity) 가 비효율적으로 높은 경우가 있는데. 이에 대해서 알아보고 Lambda를 통해 해결해보자.

친구들 이름에서 특정 알파벳으로 시작하는 친구의 이름을 찾아보자.

public class PickAnElement {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

    public static void main(String[] args) {
    
    pickName(friends, "B");
        pickName(friends, "A");
    }

    public static void pickName(final List<String> names, final String startingLetter) {
    
    String foundName = null;

        for (String name : names) {
            if (name.startsWith(startingLetter)) {
                foundName = name;
                break;
            }
        }

    
    System.out.println(String.format("A name starting with %s", startingLetter));

        if (foundName != null) {
            System.out.println(foundName);
        } else {
            System.out.println("No name found");
        }
    }
}

foundName 변수를 최초 초기화시 null 로 선언했다. 이는 잠재적인 문제가 될 수 있다.
또한 외부 데이터를 통해 루프를 수행하고 엘리먼트를 찾으면 루프를 빠져나온다. 이 또한 잠재적인 문제가 될 수 있다.

Lambda 와 Optional<T> 인터페이스를 통해 코드를 좀 더 간결하고 안정적으로 개선해보자.

public class PickAnElement {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

    public static void main(String[] args) {
    
    pickName(friends, "B");
        pickName(friends, "A");
    }

    public static void pickName(final List<String> names, final String startingLetter) {
        final Optional<String> foundName =
            names.stream()
                    .filter(name -> name.startsWith(startingLetter))
                    .findFirst();

    
    System.out.println(String.format("A name starting with %s : %s",
            startingLetter, foundName.orElse("No name found")));
    }
}

Optional<T> 클래스에 대해서 알아보자.
이는 null 값으로 인해 NullPointException 이 발생하는 것을 막아준다.
몇가지 지원하는 함수가 있는데.
  • isPresent() - 객체가 존재하는지?
  • get() - 현재 값을 가져온다.
  • orElse() - 값이 없는 경우 별도 처리를 할 수 있다.(위 코드 참조)


4. Collection Reduce

먼저 간단하게 전체 글자수를 세어보자.

public class PickALongest {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

    public static void main(String[] args) {
    
    System.out.println("Total number of characters in all names : " +
            friends.stream()
                        .mapToInt(name -> name.length())
                        .sum());
    }
}

map 오퍼레이션(mapToInt, mapToDouble) 을 통해서 값을 변형하고, reduce 오퍼레이션(sum, max, min, sorted, average) 로 결과의 길이를 reduce 시킨다.

그럼 친구들 중에 가장 긴 이름을 가진 친구 이름을 찾아 출력해보자.

public class PickALongest {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

    public static void main(String[] args) {
        final Optional<String> aLongName =
            friends.stream()
                    .reduce((name1, name2) -> name1.length() >= name2.length() ? name1 : name2);

        aLongName.ifPresent(name ->
            System.out.println(String.format("A longest name : %s", name))
        );
    }
}

reduce() 메서드에 전달되는 Lambda 표현식은 두 개의 파라메터를 갖는다. 길이를 비교하여 하나만 return 한다.
이는 reduce() 메서드가 전달되는 Lambda 표현식의 의도를 전혀 알지 못하며, 전달되는 Lambda 표현식과는 분리되어 있다.
이를 전략 패턴(Strategy Pattern) 이라고 한다.

여기서 Lambda 표현식은 BinaryOperator 라는 함수형 인터페이스로 apply() 메서드를 따른다.

R apply(T t, U u);

만약 특정 이름보다 긴 이름을 찾고자 한다면 어떻게 해야할까?

public class PickALongest {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

    public static void main(String[] args) {
        final String steveOverLonger =
            friends.stream()
                    .reduce("Steve", (name1, name2) -> name1.length() >= name2.length() ? name1 : name2);

    
    System.out.println(String.format("A name over Steve's length : %s", steveOverLonger));
    }
}

이 경우 Optional 을 return 하지 않는다. 그 이유는 만약 reduce() 한 결과가 비어 있다고 하더라도 기본값인 “Steve” 가 return 될 것이기 때문이다.


5. Element Join

친구들의 이름을 ,(쉼표) 를 구분자로 하여 출력을 해보자.

public class PrintList {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

    public static void main(String[] args) {
        for (String name : friends) {
            System.out.print(name + ", ");
        }
        System.out.println();
    }
}
public class PrintList {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

    public static void main(String[] args) {
        for (int i=0; i < friends.size() - 1; i++) {
            System.out.print(friends.get(i) + ", ");
        }

        if (friends.size() > 0) {
            System.out.println(friends.get(friends.size() - 1));
        }
    }
}

마지막 ,(쉼표)를 제거하기 위해 몇 줄의 코드가 더 필요했다.
String 클래스에 join() 메서드가 추가되면서 더 이상 이런 지저분한 코드는 필요 없게 되었다.

public class PrintList {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

    public static void main(String[] args) {
    
    System.out.println(String.join(",", friends));
    }
}

이젠 Stream API 를 통해서 Element 들을 join 해보자.

public class PrintList {
    private final static List<String> friends = Arrays.asList("Brian", "Nate", "Neal", "Raju", "Sara", "Scott");

    public static void main(String[] args) {
    
    System.out.println(
            friends.stream()
                    .map(String::toUpperCase)
                    .collect(Collectors.joining(", "))
        );
    }
}

여기까지 Java Collection 에 대해서 알아보았다.
Lambda 표현식과 Stream API 를 활용하면 코드를 좀 더 직관적이고 간단하게 구현할 수 있다.