본문 바로가기
Programing/Java

[자바8 람다의 힘] 3장 String, Comparator 그리고 filter

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

Chapter 3. String, Comparator 그리고 filter

1. String Iteration

문자열(String) 의 각 문자들을 출력해보자.

public class IteraeString {
    public static void main(String[] args) {
        final String str = "w00t";

        str.chars().forEach(System.out::println);
    }
}

2장 컬렉션 사용에서 공부한 것 처럼 method reference와 함께 사용할 수 있다.
여기서 chars() 는 CharSequence 인터페이스로부터 파생한 String 클래스의 새로운 메소드이다.

위 코드를 실행해보면 아래와 같이 숫자로 출력되는데, 이는 chars() 가 기본적으로 IntStream을 반환하기 때문이다.

119
48
48
116

그렇다면 숫자가 아닌 문자를 출력해주려면 어떻게 해야할까?
IntStream 내의 Element 들을 변형해 주어야 한다. Stream API 의 mapToObj() 를 사용해보자.

public class IteraeString {
    public static void main(String[] args) {
        final String str = "w00t";

        str.chars()
            .mapToObj(ch -> Character.valueOf((char)ch))
            .forEach(System.out::println);
    }
}
w
0
0
t

코드를 수행해보면 문자로 출력됨을 확인할 수 있다.

샘플코드에서 보면 문자열 데이터가 “w00t” 이다. 여기서 가운데 “00” 는 숫자이다.
만약 이렇게 숫자만 출력하고 싶으면 어떻게 할까? filter() 를 활용할 수 있다.

public class IteraeString {
    public static void main(String[] args) {
        final String str = "w00t";

        str.chars()
            .filter(Character::isDigit)
            .mapToObj(ch -> Character.valueOf((char)ch))
            .forEach(System.out::println);
    }
}

Character 클래스 내에 숫자 여부를 판단하는 isDigit() 이라는 static method를 사용하면 된다.
아주 간단하게 해결 되었다.

2. Comparator 인터페이스

Comparator 인터페이스는 Java에서 자주 사용되는 인터페이스 중 하나이다.
검색부터 정렬, 역정렬 등 많이 사용되는데, Java8에서는 함수형 인터페이스로 변경되었다.

Comparator를 이용해서 정렬을 한 번 해보자.
예를 들어 사람들 정보가 있을 때, 나이순으로 정렬하는 문제를 해결해 보자.

먼저 Person 클래스를 하나 생성한다.

public class Person {
    private final String name;
    private final int age;

    public Person(final String name, final int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    public int ageDifference(final Person other) {
        return age - other.age;
    }

    public String toString() {
        return String.format("%s - %s", name, age);
    }
}

나이 오름차순으로 정렬 해보자.

public class Compare {
    private static final List<Person> people = Arrays.asList(
        new Person("John", 20),
        new Person("Sara", 21),
        new Person("Jane", 21),
        new Person("Greg", 35)
    );

    public static void main(String[] args) {
    
    List<Person> ascendingAge = people.stream()
                                                        .sorted((person1, person2) -> person1.ageDifference(person2))
                                                        .collect(Collectors.toList());
        printPeople("Sorted in ascending order by age : ", ascendingAge);
    }

    private static void printPeople(String message, List<Person> people) {
    
    System.out.println(message);
        people.forEach(System.out::println);
    }
}
Sorted in ascending order by age :
John - 20
Sara - 21
Jane - 21
Greg - 35

코드를 수행해보면 나이 오름차순으로 잘 정렬됨을 확인할 수 있다.

.sorted((person1, person2) -> person1.ageDifference(person2))
.sorted(Person::ageDifference)

위처럼 sorted() 함수 파라메터인 람다 표현식을 개선할 수 도 있다.

이제 내림차순으로도 정렬해보자.

.sorted((person1, person2) -> person2.ageDifference(person1))

sorted() 람다표현식을 위 처럼 변경해주면 된다.

Comparator<T> 인터페이스를 적용하여 재사용 가능한 Comparator 로 만들어 둘 수 있다.

public class Compare {
    private static final List<Person> people = Arrays.asList(
        new Person("John", 20),
        new Person("Sara", 21),
        new Person("Jane", 21),
        new Person("Greg", 35)
    );

    private static final Comparator<Person> ascending = (person1, person2) -> person1.ageDifference(person2);
    private static final Comparator<Person> descending = ascending.reversed();

    public static void main(String[] args) {
    
    printPeople("Sorted in ascending order by age : ",
            people.stream()
                .sorted(ascending)
                .collect(Collectors.toList()));
        printPeople("Sorted in descending order by age : ",
            people.stream()
                .sorted(descending)
                .collect(Collectors.toList()));
    }

    private static void printPeople(String message, List<Person> people) {
    
    System.out.println(message);
        people.forEach(System.out::println);
    }
}

이제 정렬문제는 해결되었다.
그럼 가장 젊은 사람 또는 가장 나이가 많은 사람을 선택해보자.

public class Compare {
    private static final List<Person> people = Arrays.asList(
        new Person("John", 20),
        new Person("Sara", 21),
        new Person("Jane", 21),
        new Person("Greg", 35)
    );

    public static void main(String[] args) {
        people.stream()
                .min(Person::ageDifference)
                .ifPresent(youngest -> System.out.println(youngest));
    }
}
public class Compare {
    private static final List<Person> people = Arrays.asList(
        new Person("John", 20),
        new Person("Sara", 21),
        new Person("Jane", 21),
        new Person("Greg", 35)
    );


    public static void main(String[] args) {
        people.stream()
                .max(Person::ageDifference)
                .ifPresent(eldest -> System.out.println(eldest));
    }
}

min(), max() 함수는 Optional 타입을 반환하는데, 이는 List가 비어 있는 경우 가장 어린 사람 또는 가장 나이가 많은 사람이 존재하지 않을 수 있기 때문이다.

3. 여러가지 비교 연산

지금까지 나이 순으로 정렬, 가장 어린 사람 또는 나이가 많은 사람을 선택해 보았다.
이제는 이름 순으로 정렬을 해보자.

단순히 ageDifference라는 함수처럼 name 변수에 대해서도 추가하여 method reference 를 적용해 볼 수 있지만, Comparator 인터페이스에 새로 추가된 method를 이용하여 간단하게 구현해보자.


public class Compare {
    private static final List<Person> people = Arrays.asList(
        new Person("John", 20),
        new Person("Sara", 21),
        new Person("Jane", 21),
        new Person("Greg", 35)
    );


    public static void main(String[] args) {
        people.stream()
                .sorted((person1, person2) ->
                            person1.getName().compareTo(person2.getName()))
                .forEach(System.out::println);;
    }
}
public class Compare {
    private static final List<Person> people = Arrays.asList(
        new Person("John", 20),
        new Person("Sara", 21),
        new Person("Jane", 21),
        new Person("Greg", 35)
    );

    public static void main(String[] args) {
        final Function<Person, String> byName = person -> person.getName();
        final Function<Person, Integer> byAge = person -> person.getAge();

    
    printPeople("이름순으로 정렬", people.stream()
                                                        .sorted(Comparator.comparing(byName))
                                                        .collect(Collectors.toList()));
        printPeople("나이순으로 정렬", people.stream()
                                                        .sorted(Comparator.comparing(byAge))
                                                        .collect(Collectors.toList()));
    }

    private static void printPeople(String message, List<Person> people) {
    
    System.out.println(message);
        people.forEach(System.out::println);
    }
}

Comparator.comparing() 를 이용하여 좀 더 간편하게 구현할 수 있다.

4. collect() 와 Collectors 클래스

collect() 함수에 대해서 좀 더 알아보도록 하자.

먼저 people 변수 내에서 20살 이상의 사람들만 선택하는 코드를 생성해보면 아래와 같이 작성할 수 있을 것이다.

public class OlderThan20 {
    private static final List<Person> people = Arrays.asList(
        new Person("John", 20),
        new Person("Sara", 21),
        new Person("Jane", 21),
        new Person("Greg", 35)
    );

    public static void main(String[] args) {
    
    List<Person> olderThan20 = new ArrayList<Person>();
        people.stream()
                .filter(person -> person.getAge() > 20)
                .forEach(person -> olderThan20.add(person));

    
    System.out.println("People older than 20 : " + olderThan20);
    }
}

코드를 수행해보면 결과도 잘 나온다. 원하는 결과를 얻었지만 여기에는 몇 가지 문제점이 있다.

1. 타켓 컬렉션(olderThan20) 에 Element를 추가하는 Operation이 너무 low level이다.
     자바8에서는 서술적 프로그래밍을 권장하는데, 이는 너무 명령적이다.
2. Thread unsafe
     병렬처리시 문제가 될 수 있다.

즉, 2번 항목인 병렬처리시 Thread-unsafe 하다는 것인데. collect() 를 이용하면 손쉽게 해결이 가능하다.

collect() 는 Element 에 대한 Stream 을 가지며, 결과 컨테이너에 각 Stream 들을 모은다.
* 결과 컨테이너를 만드는 방법(ArrayList::new)
* 하나의 Element 를 결과 컨테이너에 추가하는 방법(ArrayList::add)
* 하나의 결과 컨테이너를 다른 것과 합치는 방법(ArrayList::addAll)

public class OlderThan20 {
    private static final List<Person> people = Arrays.asList(
        new Person("John", 20),
        new Person("Sara", 21),
        new Person("Jane", 21),
        new Person("Greg", 35)
    );

    public static void main(String[] args) {
    
    List<Person> olderThan20 =
            people.stream()
                    .filter(person -> person.getAge() > 20)
                    .collect(ArrayList::new, ArrayList::add, ArrayList::addAll);

    
    System.out.println("People older than 20 : " + olderThan20);
    }
}

이로써 서술적인 프로그래밍이 되었고, Thread-safe를 보장하게 되었다.
참고로 ArrayList 가 Thread-unsafe 하더라도 문제가 되지 않는다.

Collectors 클래스를 활용하여 좀 더 간단하게 프로그래밍을 할 수도 있다.

public class OlderThan20 {
    private static final List<Person> people = Arrays.asList(
        new Person("John", 20),
        new Person("Sara", 21),
        new Person("Jane", 21),
        new Person("Greg", 35)
    );

    public static void main(String[] args) {
    
    List<Person> olderThan20 =
            people.stream()
                    .filter(person -> person.getAge() > 20)
                    .collect(Collectors.toList());

    
    System.out.println("People older than 20 : " + olderThan20);
    }
}

toList() 외에도 다양한 편리한 메소드를 제공한다.
* toSet()
* toMap()
* joining()
* mapping()
* collectingAndThen()
* minBy()
* maxBy()
* groupingBy()

Collectors 에서 제공하는 groupingBy룰 한 번 사용 해보자.
사람들 정보를 나이별로 grouping 해보자.

public class OlderThan20 {
    private static final List<Person> people = Arrays.asList(
        new Person("John", 20),
        new Person("Sara", 21),
        new Person("Jane", 21),
        new Person("Greg", 35)
    );

    public static void main(String[] args) {
    
    Map<Integer, List<String>> nameOfPeopleByAge =
            people.stream()
                    .collect(Collectors.groupingBy(
                                Person::getAge,
                                Collectors.mapping(Person::getName, Collectors.toList()))
                    );

    
    System.out.println("People grouped by age : " + nameOfPeopleByAge);
    }
}

여기서 groupingBy() 는 두 개의 파라미터를 가진다.
1. 그룹을 만드는 기준
     여기서는 나이이다.
2. mapping() 의 결과인 Collector
     나이로 그룹핑한 결과에 대한 Stream값

좀 더 복잡한 예제를 한 번 수행해보자.
이름의 첫 글자를 기준으로 그룹핑하고 각 그룹별로 나이가 가장 많은 사람의 정보를 가지도록 해보자.

public class OlderThan20 {
    private static final List<Person> people = Arrays.asList(
        new Person("John", 20),
        new Person("Sara", 21),
        new Person("Jane", 21),
        new Person("Greg", 35)
    );

    public static void main(String[] args) {
    
    Comparator<Person> byAge = Comparator.comparing(Person::getAge);

    
    Map<Character, Optional<Person>> oldestPersonOfEachLetter =
            people.stream()
                    .collect(Collectors.groupingBy(
                            person -> person.getName().charAt(0),
                            Collectors.reducing(BinaryOperator.maxBy(byAge)))
                    );

        System.out.println("People grouped by age : " + oldestPersonOfEachLetter);
    }
}

1. 기준 : 각 사람 이름의 첫 글자
     person -> person.getName().charAt(0)
2. 그룹핑
     2-1. reducing 적용
          그룹핑된 각 Stream에 reducing() 연산 적용
     2-2 BinaryOperator 적용
          reducing() 연산 수행시 비교 기준 정의

이렇게 Collectors 클래스를 잘 활용하면 복잡한 Operation 도 서술식으로 프로그래밍 할 수 있을 것으로 생각이된다.

5. 디렉토리 모든 파일 리스트

File 클래스의 list() 나 listFiles() 를 이용하면 특정 디렉토리 하위의 파일 리스트를 가져올 수 있다.
이를 함수형 인터페이스를 도입해서 파일 리스트를 iteration 해보자.

이를 위해서 CloseableStream 을 이용하여 구현해야 한다.

public class ListFiles {
    public static void main(String[] args) throws Exception {
    
    Files.list(Paths.get(".")).forEach(System.out::println);
    }
}
./.classpath
./.git
./.gitignore
./.project
./.settings
./build
./README.md
./src

코드를 수행해보면 현재 디렉토리(\.) 하위에 있는 모든 파일 리스트들이 출력된다.

Files 클래스의 list() 메소드를 사용하여 CloseableStream 을 얻어오게 된다.
Files.list() 를 한 번 살펴보자.

public static Stream<Path> list(Path dir) throws IOException {
    DirectoryStream<Path>
ds = Files.newDirectoryStream(dir);
   
try {
       
final Iterator<Path> delegate = ds.iterator();

       
// Re-wrap DirectoryIteratorException to UncheckedIOException
        Iterator<Path>
it = new Iterator<Path>() {
           
@Override
           
public boolean hasNext() {
               
try {
                   
return delegate.hasNext();
                }
catch (DirectoryIteratorException e) {
                   
throw new UncheckedIOException(e.getCause());
                }
            }
           
@Override
           
public Path next() {
               
try {
                   
return delegate.next();
                }
catch (DirectoryIteratorException e) {
                   
throw new UncheckedIOException(e.getCause());
                }
            }
        };

       
return StreamSupport.stream(Spliterators.spliteratorUnknownSize(it, Spliterator.DISTINCT), false)
                            .onClose(asUncheckedRunnable(
ds));
    }
catch (Error|RuntimeException e) {
       
try {
           
ds.close();
        }
catch (IOException ex) {
           
try {
               
e.addSuppressed(ex);
            }
catch (Throwable ignore) {}
        }
       
throw e;
    }
}

내부 상세 구현 내용은 몰라도 무방하다.
Path 타입의 찾고자 하는 디렉토리를 파라메터로 받아서 Path Stream 을 반환한다.
Path Stream 을 통해서 iteration을 하게되는 것이다.

만약 디렉토리만 출력하고 싶다면, 
filter() 를 이용하여 Stream Element 중 일부를 선택할 수 있다.

예를 들어서 특정 디렉토리 하위 파일 중 .java 파일만 가져오도록 해보자.

public class ListSelectFiles {
    public static void main(String[] args) {
        final String[] files =
            new File("./src/main/com/silverboyf/java8/sample/ch3")
            .list(
                    new FilenameFilter() {
                        @Override
                        public boolean accept(File dir, String name) {
                            return name.endsWith(".java");
                        }
                    }
            );

    
    Arrays.asList(files)
            .stream()
            .forEach(System.out::println);
    }
}
Compare.java
IteraeString.java
ListFiles.java
ListSelectFiles.java
OlderThan20.java
Person.java

FilenameFilter 인터페이스 구현을 통해서 특정 파일을 선택할 수 있다.
하지만 뭔가 코드가 복잡해보이고, 서술적이지 않다는 느낌을 받는다.

자바8의 DirectoryStream 을 이용하면 좀 더 간단하고 서술적으로 구현이 가능하다.

public class ListSelectFiles {
    public static void main(String[] args) throws Exception {
    
    Files.newDirectoryStream(
            Paths.get("./src/main/com/silverboyf/java8/sample/ch3"),
            path -> path.toString().endsWith(".java")
        )
        .forEach(System.out::println);
    }
}
./src/main/com/silverboyf/java8/sample/ch3/Compare.java
./src/main/com/silverboyf/java8/sample/ch3/IteraeString.java
./src/main/com/silverboyf/java8/sample/ch3/ListFiles.java
./src/main/com/silverboyf/java8/sample/ch3/ListSelectFiles.java
./src/main/com/silverboyf/java8/sample/ch3/OlderThan20.java
./src/main/com/silverboyf/java8/sample/ch3/Person.java

6. flatMap 과 서브 디렉토리

주어진 디렉토리 하위에 존재하는 파일 또는 서브 디렉토리 리스트를 출력하는 방법에 대해서 알아 보았다.
만약 서브 디렉토리 하위의 파일에 대해서도 함께 출력하고 싶다면 어떻게 해야할까?

public class ListSubDirs {
    public static void main(String[] args) {
    
    listTheHardWay();
        System.out.println();
        listTheBetterWay();
    }

    public static void listTheHardWay() {
    
    List<File> files = new ArrayList<File>();

    
    File[] filesInCurrentDir = new File(".").listFiles();
        for (File file : filesInCurrentDir) {
            File[] filesInSubDir = file.listFiles();
            if (filesInSubDir != null) {
                files.addAll(Arrays.asList(filesInSubDir));
            } else {
                files.add(file);
            }
        }

    
    System.out.println(files);
        System.out.println("Count : " + files.size());
    }

    public static void listTheBetterWay() {
    
    List<File> files =
            Stream.of(new File(".").listFiles())
            .flatMap(file -> file.listFiles() == null ?
                Stream.of(file) : Stream.of(file.listFiles()))
                .collect(Collectors.toList());

        System.out.println(files);
        System.out.println("Count : " + files.size());
    }
}

flatMap() 을 사용하면 다중 Stream 을 하나의 flat Stream 에 할당할 수 있다.

7. 파일 변경 살펴보기

특정 디렉토리에 파일이 생성되거나 수정 또는 삭제됐을 때 알고 싶은 경우를 구현해보자.

아래 샘플 코드는 대부분 자바7부터 제공하던 기능이며, 자바8에서 달라진 부분은 내부 iterator의 편의성이다.

final Path path = Paths.get(".");
final WatchService watchService = path.getFileSystem().newWatchService();

path.register(watchService, StandardWatchEventKinds.ENTRY_MODIFY);

System.out.println("Report and file changed within new 1 minute...");
final WatchKey watchKey = watchService.poll(1, TimeUnit.MINUTES);

if (watchKey != null) {
    watchKey.pollEvents()
    
    .stream()
        .forEach(event ->
            System.out.println(event.context()));
}
Report and file changed within new 1 minute...

먼저 원하는 디렉토리에 위 코드처럼 WatchService 를 하나 등록하자.
만약 해당 Path에 수정이 발생하면 WatchKey 를 통해 알려준다.
WatchKey를 통해 event 를 받아서 어떤 파일이 변경이 되었는지 확인할 수 있다.



혹시 Stream 형식의 데이터를 다루는데, 조금 어렵게 느껴질지도 모르겠다. 하지만 걱정하지 말자.
익숙해지면 이런 방식의 프로그래밍이 얼마나 편하고 강력한지 깨닫게 될 것이다.