Wednesday 27 May 2015

[Java / Guava] How to generate map while having either key or value ?

Map is a data structure which every software developer uses on a daily basis. Maps are generally created to provide a quick access to some object associated to a key. On the other hand we often create maps just have a more conveniant access to a collection of objects. Consider the following class as a main domain object:
public class Book {
 private final String title;
 private final String isbn;

 public Book(String title, String isbn) {
  this.title = title;
  this.isbn = isbn;
 }

 public String getTitle() {
  return title;
 }

 public String getIsbn() {
  return isbn;
 }
}
In case we want to retrieve a book of given isbn number from a list we would do something like this:
public class BookShelf {
    private static final List<Book> books = ImmutableList.of(
            new Book("Metro 2033", "54213452435"),
            new Book("The Witcher", "123123123")
    );

    public Book getBookByIsbn(String isbn) {
        for (Book book : books) {
            if (book.getIsbn().equals(isbn)) {
                return book;
            }
        }

        return null;
    }
}
Here's a simple unit test which checks whether the book has been found:
@Test
    public void shouldReturnMetro() throws Exception {
        // given
        String isbn = "54213452435";

        // when
        final Book book = new BookShelf().getBookByIsbn(isbn);

        // then
        assertThat(book.getTitle()).isEqualTo("Metro 2033");
    }
As you can see we have to iterate through the list and check whether book's isbn is equal to the one passed to the method. Such construction increases method complexity (if you use sonar or some similar tool you probably try to keep as lowest as it's possible). Other thing you have to take care of is a value returned in case the book of given isbn does't exist. I think I chose the worst possible solution which is returning null :) Other options may be:
  • -throwing exception
  • -null object pattern
Let's see how it would look if the books were stored in a map.
public class BookShelf {
    private static final Map<String, Book> books = ImmutableMap.of(
            "54213452435", new Book("Metro 2033", "54213452435"),
            "123123123", new Book("The Witcher", "123123123")
    );

    public Book getBookByIsbn(String isbn) {
        return books.get(isbn);
    }
}
I think it looks much better now. The unit test still passess and null value is returned by default when value for a given key doesn't exist. In real life such map won't exist Typically you fetch books from database or some restful service so it will be a list of books not the map. Let's see how to generate a map from the list in traditional imperative way:
public class BookShelf {
    private final Map<String, Book> books;

    public BookShelf(List<Book> books) {
        Map<String, Book> booksMap = Maps.newHashMap();
        for (Book book : books) {
            booksMap.put(book.getIsbn(), book);
        }

        this.books = booksMap;
    }

    public Book getBookByIsbn(String isbn) {
        return books.get(isbn);
    }
}
And the unit test:
@Test
    public void shouldReturnMetro() throws Exception {
        // given
        List<Book> books = ImmutableList.of(
                new Book("Metro 2033", "54213452435"),
                new Book("The Witcher", "123123123")
        );

        // when
        final Book book = new BookShelf(books).getBookByIsbn("54213452435");

        // then
        assertThat(book.getTitle()).isEqualTo("Metro 2033");
    }
Works fine... but I really don't like the way it's been done. Looks just ugly. Fortunately Guava can help here :) There's a uniqueIndex() method in Maps class which takes Iterable and Function as arguments.
public static <K, V> ImmutableMap<K, V> uniqueIndex(
      Iterable<V> values, Function<? super V, K> keyFunction) {
    return uniqueIndex(values.iterator(), keyFunction);
  }
The fcuntion will be applied to every book in Iterable and will generate a key so uniqueIndex() returns ImmutableMap in which value returned from function is a key and a book a value.
public class BookShelf {
    private final Map<String, Book> books;

    public BookShelf(List<Book> books) {
        this.books = Maps.uniqueIndex(books, new Function<Book, String>() {
            public String apply(Book book) {
                return book.getIsbn();
            }
        });
    }

    public Book getBookByIsbn(String isbn) {
        return books.get(isbn);
    }
}
Much better now. The Function may obviously be injected. In such case changing the function becomes extremely easy. Consider the following example in Spring:
@Component
public class BookShelf {
    private final Map<String, Book> books;

    @Autowired
    public BookShelf(ImmutableList<Book> books, Function<Book, String> bookIndexFunction) {
        this.books = Maps.uniqueIndex(books, bookIndexFunction);
    }

    public Book getBookByIsbn(String isbn) {
        return books.get(isbn);
    }
}
And the configuration:
@Configuration
@ComponentScan("gt.dev.sample")
public class BookshelfConfig {
    @Bean
    public Function<Book, String> bookIndexFunction() {
        return new Function<Book, String>() {
            public String apply(Book book) {
                return book.getIsbn();
            }
        };
    }

    @Bean
    public ImmutableList<Book> books() {
        return ImmutableList.of(
                new Book("Metro 2033", "54213452435"),
                new Book("The Witcher", "123123123")
        );
    }
}
It's just an example so I've injected the books as well. Let's run the application:
public class App {
    public static void main( String[] args ) {
        final BookShelf bookShelf = new AnnotationConfigApplicationContext(BookshelfConfig.class).getBean(BookShelf.class);
        final Book bookByIsbn = bookShelf.getBookByIsbn("54213452435");
        System.out.println(bookByIsbn);
    }
}
And the output is:
maj 26, 2015 1:42:46 PM org.springframework.context.annotation.AnnotationConfigApplicationContext prepareRefresh
INFO: Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@67eaf25d: startup date [Tue May 26 13:42:46 CEST 2015]; root of context hierarchy
Book{title=Metro 2033, isbn=54213452435}
Now let's say that I want to generate map while having only a list of keys. uniqueIndex() method cannot be used because it does the opposite so Guava Maps class contains following methods:
  • asMap()
  • toMap()
The only difference between those two methods is that the toMap() method returns an instance of ImmutableMap while asMap() returns a view of the original map. Now I've added the following bean into the configuration:
@Bean
    public ImmutableList<String> theWitcherIsbns() {
        return ImmutableList.of(
                "325252525", 
                "432653462", 
                "23463667", 
                "324632636");
    }
The list contains ISBN numbers of different editions of The Witcher book. I want to create a map in which the key is ISBN and the value an object of Book class. In traditional imperative way you would write another foreach loop which creates an object of Book class and puts it into the map which has to be created before the loop as well. Using Guava it can be written like that:
@Component
public class BooksGenerator {
    private final ImmutableMap<String, Book> theWitcherBooks;

    @Autowired
    public BooksGenerator(ImmutableList<String> theWitcherIsbns) {
        this.theWitcherBooks = Maps.toMap(theWitcherIsbns, new Function<String, Book>() {
            public Book apply(String isbn) {
                return new Book("The Witcher", isbn);
            }
        });
    }

    public Book getWitcherBookByIsbn(String isbn) {
        return theWitcherBooks.get(isbn);
    }
}
After starting the application:
public class App {
    public static void main( String[] args ) {
        final BooksGenerator booksGenerator = new AnnotationConfigApplicationContext(BookshelfConfig.class).getBean(BooksGenerator.class);
        final Book book = booksGenerator.getWitcherBookByIsbn("23463667");
        System.out.println(book);
    }
}
I get a proper book:
maj 26, 2015 4:53:27 PM org.springframework.context.annotation.AnnotationConfigApplicationContext prepareRefresh
INFO: Refreshing org.springframework.context.annotation.AnnotationConfigApplicationContext@6f609af9: startup date [Tue May 26 16:53:27 CEST 2015]; root of context hierarchy
Book{title=The Witcher, isbn=23463667}
asMap() method works the same but as I've mentioned before it returns a view of original map. I strongly recommend using toMap() as immutable collections are more safe (threads) and it's harder to complicate the code using them.

No comments:

Post a Comment