diff --git a/part_4/exercise_4/.gitignore b/part_4/exercise_4/.gitignore
new file mode 100644
index 0000000000000000000000000000000000000000..4dc3c31488d72e7da5e33ad0bee70a46afd7f69f
--- /dev/null
+++ b/part_4/exercise_4/.gitignore
@@ -0,0 +1,3 @@
+/.idea
+/out
+/exercise_4.iml
\ No newline at end of file
diff --git a/part_4/exercise_4/Main.java b/part_4/exercise_4/Main.java
new file mode 100644
index 0000000000000000000000000000000000000000..5ffb3e1bd7060cefc1b6814b3197fc8f5d4d82e2
--- /dev/null
+++ b/part_4/exercise_4/Main.java
@@ -0,0 +1,11 @@
+package fi.utu.tech.ooj.exercise4;
+
+import fi.utu.tech.ooj.exercise4.exercise4.Exercise4;
+
+public class Main {
+    public static void main(String[] args) throws Exception {
+        System.out.println("Advanced Course in Object-Oriented Programming, Part 4 Exercises");
+
+        new Exercise4();
+    }
+}
diff --git a/part_4/exercise_4/books.zip b/part_4/exercise_4/books.zip
new file mode 100644
index 0000000000000000000000000000000000000000..7c87031bc1d5b835078c9379313ecb4486c09852
Binary files /dev/null and b/part_4/exercise_4/books.zip differ
diff --git a/part_4/exercise_4/exercise4/Exercise4.java b/part_4/exercise_4/exercise4/Exercise4.java
new file mode 100644
index 0000000000000000000000000000000000000000..d297a12a20c36b28993eb279ededc8cd8e56f18b
--- /dev/null
+++ b/part_4/exercise_4/exercise4/Exercise4.java
@@ -0,0 +1,27 @@
+package fi.utu.tech.ooj.exercise4.exercise4;
+
+import java.io.IOException;
+
+public class Exercise4 {
+    public Exercise4() {
+        System.out.println("Exercise 4");
+
+        System.out.println("Exercise 1");
+        try (var zipper = new TestZipper("books.zip")) {
+            zipper.run();
+        } catch (IOException e) {
+            System.err.println("Execution failed!");
+            e.printStackTrace();
+        }
+
+        System.out.println();
+        System.out.println("-----------------------------------------");
+        System.out.println("Exercise 2");
+        try (var zipper = new TestZipper2("books.zip")) {
+            zipper.run();
+        } catch (IOException e) {
+            System.err.println("Execution failed!");
+            e.printStackTrace();
+        }
+    }
+}
diff --git a/part_4/exercise_4/exercise4/TestZipper.java b/part_4/exercise_4/exercise4/TestZipper.java
new file mode 100644
index 0000000000000000000000000000000000000000..037c6c12ccb2513fb447e2f2e135187bf4ba0982
--- /dev/null
+++ b/part_4/exercise_4/exercise4/TestZipper.java
@@ -0,0 +1,54 @@
+package fi.utu.tech.ooj.exercise4.exercise4;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.regex.Pattern;
+
+/**
+ * Test zipper.
+ * <p>
+ * Extracts zip, iterates through the files and prints information from each file.
+ * <p>
+ * From each file there will be printed:
+ * - name
+ * - amount of lines
+ * - amount of words
+ */
+class TestZipper extends Zipper<Void> {
+    TestZipper(String zipFile) throws IOException {
+        super(zipFile);
+    }
+
+    @Override
+    protected Handler<Void> createHandler(Path file) {
+        return new Handler<>(file) {
+            @Override
+            public Void handle() throws IOException {
+                var regex = Pattern.compile("\\W");
+                var contents = Files.readString(file);
+                var lines = Files.readAllLines(file);
+                var firstLine = lines.isEmpty() ? "unknown" : lines.getFirst();
+                var words = regex.splitAsStream(contents).filter(s -> !s.isBlank()).map(String::toLowerCase).toList();
+
+                System.out.printf("""
+                                                                
+                                Originally was fetched from %s.
+                                The founded file is %s.
+                                The file contains %d lines.
+                                The file contains %d words.
+                                Possible title of the work: %s
+                                                                
+                                """,
+                        tempDirectory,
+                        file.getFileName(),
+                        lines.size(),
+                        words.size(),
+                        firstLine
+                );
+
+                return null;
+            }
+        };
+    }
+}
diff --git a/part_4/exercise_4/exercise4/TestZipper2.java b/part_4/exercise_4/exercise4/TestZipper2.java
new file mode 100644
index 0000000000000000000000000000000000000000..d1115ecb89f5dd9b9a555d09c2773dda1c7b773d
--- /dev/null
+++ b/part_4/exercise_4/exercise4/TestZipper2.java
@@ -0,0 +1,239 @@
+package fi.utu.tech.ooj.exercise4.exercise4;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.*;
+import java.util.function.Consumer;
+import java.util.regex.Pattern;
+import java.util.stream.Collectors;
+
+import static java.util.stream.Collectors.toCollection;
+
+class Book implements Comparable<Book> {
+    final Path file;
+    final String title;
+    final int numberOfLines;
+    List<String> words;
+    List<String> uniqueWords;
+
+    public Book(Path file, String title, int numberOfLines) {
+        Objects.requireNonNull(file);
+        Objects.requireNonNull(title);
+        this.file = file;
+        this.title = title;
+        this.numberOfLines = numberOfLines;
+    }
+
+    @Override
+    public int compareTo(Book book) {
+        return title.compareTo(book.getTitle());
+    }
+
+    @Override
+    public String toString() {
+        return "File: " + file
+                + ". Title:" + title
+                + ". Number of lines: " + numberOfLines;
+    }
+
+    public List<String> words() throws IOException {
+        if (words == null) {
+            var regex = Pattern.compile("\\W");
+            var contents = Files.readString(file);
+
+            words = regex.splitAsStream(contents).filter(s -> !s.isBlank()).map(String::toLowerCase).toList();
+        }
+
+        return words;
+    }
+
+    public List<String> uniqueWords() throws IOException {
+        if (uniqueWords == null) {
+            List<String> words = words();
+
+            uniqueWords = words.stream()
+                    .distinct()
+                    .toList();
+        }
+
+        return uniqueWords;
+    }
+
+    public String getTitle() {
+        return title;
+    }
+
+    public int getNumberOfLines() {
+        return numberOfLines;
+    }
+}
+
+interface BookSorter<B extends Book> {
+    <T extends B> List<T> sortByTitleAsc(List<T> books);
+
+    <T extends B> List<T> sortByNumberOfLineAsc(List<T> books);
+
+    <T extends B> List<T> sortByNumberOfUniqueWordsDesc(List<T> books);
+
+    <T extends B> List<T> sortByTitleAscThenNumberOfUniqueWordsDesc(List<T> books);
+}
+
+class MutableBookSorter<B extends Book> implements BookSorter<B> {
+    private final Comparator<B> naturalComparator;
+    private final Comparator<B> lineCountComparator;
+    private final Comparator<B> uniqueWordsComparator;
+
+    public MutableBookSorter() {
+        naturalComparator = Comparator.naturalOrder();
+        lineCountComparator = Comparator.comparing(B::getNumberOfLines);
+        uniqueWordsComparator = (book1, book2) -> {
+            int uniqueWordsCount1, uniqueWordsCount2;
+
+            try {
+                uniqueWordsCount1 = book1.uniqueWords().size();
+                uniqueWordsCount2 = book2.uniqueWords().size();
+            } catch (IOException e) {
+                throw new RuntimeException(e);
+            }
+
+            return uniqueWordsCount2 - uniqueWordsCount1;
+        };
+    }
+
+    @Override
+    public <T extends B> List<T> sortByTitleAsc(List<T> books) {
+        books.sort(naturalComparator);
+
+        return books;
+    }
+
+    @Override
+    public <T extends B> List<T> sortByNumberOfLineAsc(List<T> books) {
+        books.sort(lineCountComparator);
+
+        return books;
+    }
+
+    @Override
+    public <T extends B> List<T> sortByNumberOfUniqueWordsDesc(List<T> books) {
+        books.sort(uniqueWordsComparator);
+
+        return books;
+    }
+
+    @Override
+    public <T extends B> List<T> sortByTitleAscThenNumberOfUniqueWordsDesc(List<T> books) {
+        books.sort(naturalComparator.thenComparing(uniqueWordsComparator));
+
+        return books;
+    }
+}
+
+class ImmutableBookSorter<B extends Book> extends MutableBookSorter<B> {
+    @Override
+    public <T extends B> List<T> sortByTitleAsc(List<T> books) {
+        var copiedBooks = copy(books);
+
+        return super.sortByTitleAsc(copiedBooks);
+    }
+
+    @Override
+    public <T extends B> List<T> sortByNumberOfLineAsc(List<T> books) {
+        var copiedBooks = copy(books);
+
+        return super.sortByNumberOfLineAsc(copiedBooks);
+    }
+
+    @Override
+    public <T extends B> List<T> sortByNumberOfUniqueWordsDesc(List<T> books) {
+        var copiedBooks = copy(books);
+
+        return super.sortByNumberOfUniqueWordsDesc(copiedBooks);
+    }
+
+    @Override
+    public <T extends B> List<T> sortByTitleAscThenNumberOfUniqueWordsDesc(List<T> books) {
+        var copiedBooks = copy(books);
+
+        return super.sortByTitleAscThenNumberOfUniqueWordsDesc(copiedBooks);
+    }
+
+    private <T extends B> List<T> copy(List<T> books) {
+        return new ArrayList<>(books);
+    }
+}
+
+class TestZipper2 extends Zipper<Book> {
+    TestZipper2(String zipFile) throws IOException {
+        super(zipFile);
+    }
+
+    @Override
+    protected Handler<Book> createHandler(Path file) {
+        return new Handler<>(file) {
+            @Override
+            public Book handle() throws IOException {
+                var lines = Files.readAllLines(file);
+                var firstLine = lines.isEmpty() ? "unknown" : lines.getFirst();
+
+                return new Book(file, firstLine, lines.size());
+            }
+        };
+    }
+
+    @Override
+    protected FileObjectsHandler<Book> createFileObjectsHandler() {
+        return books -> {
+            List<Book> sortedBooks;
+            BookSorter<Book> sorter = new ImmutableBookSorter<>();
+            Consumer<Book> printBookInformation = (Book book) -> {
+                System.out.println("Book: " + book);
+
+                try {
+                    List<String> uniqueWords = book.uniqueWords();
+
+                    System.out.println("Unique words: " + uniqueWords);
+                    System.out.println("Unique words count: " + uniqueWords.size());
+                    System.out.println();
+                } catch (IOException e) {
+                    throw new RuntimeException(e);
+                }
+            };
+
+            System.out.println();
+            System.out.println("Original books:");
+            books.forEach(System.out::println);
+
+            System.out.printf("""
+
+                Handled %d Books.
+                Now we could sort it out a bit.
+
+                """, books.size());
+
+            System.out.println("1. Sort by title (asc):");
+            sortedBooks = sorter.sortByTitleAsc(books);
+            sortedBooks.forEach(System.out::println);
+
+            System.out.println();
+            System.out.println("2. Sort by number of lines (asc):");
+            sortedBooks = sorter.sortByNumberOfLineAsc(books);
+            sortedBooks.forEach(System.out::println);
+
+            System.out.println();
+            System.out.println("3. Sort by number of unique words (desc):");
+            sortedBooks = sorter.sortByNumberOfUniqueWordsDesc(books);
+            sortedBooks.forEach(printBookInformation);
+
+            System.out.println();
+            System.out.println("4. Sort by title (asc) then number of unique words (desc):");
+            sortedBooks = sorter.sortByTitleAscThenNumberOfUniqueWordsDesc(books);
+            sortedBooks.forEach(printBookInformation);
+
+            System.out.println();
+            System.out.println("Original books:");
+            books.forEach(System.out::println);
+        };
+    }
+}
diff --git a/part_4/exercise_4/exercise4/Zipper.java b/part_4/exercise_4/exercise4/Zipper.java
new file mode 100644
index 0000000000000000000000000000000000000000..050aa2851b9c86da5ddb24c4bb90254d8836ca10
--- /dev/null
+++ b/part_4/exercise_4/exercise4/Zipper.java
@@ -0,0 +1,192 @@
+package fi.utu.tech.ooj.exercise4.exercise4;
+
+import fi.utu.tech.ooj.exercise4.Main;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.zip.ZipInputStream;
+
+// WORKAROUND: if the zip file is not found, copy books.zip from the resources directory
+// to the project's root and follow the two instructions below,
+// also marked with WORKAROUND comments.
+
+/**
+
+A class that models unzipping (extracting a compressed zip package).
+<p>
+The idea is that while an object of the class exists, there is also a temporary directory
+created by the object on the disk. When the object is closed, the directory is also deleted.
+<p>
+How to use it? Create an object. Creation assumes that the zip file must exist.
+The class's 'run' method activates the unzipping. Finally, close the object ('close').
+<p>
+Hint: closing is easy with Java's try-with-resources feature.
+*/
+abstract public class Zipper<E> implements AutoCloseable {
+    // zip-file for unzipping
+    private final String zipFile;
+
+    // java class, from which package the zip file is looked for
+    private final Class<?> resolver = Main.class;
+
+    // path of temp directory
+    protected final Path tempDirectory;
+
+    private final List<E> objects;
+
+    /**
+     * Records the given zip file and
+     * creates a temporary directory 'tempDirectory'.
+     *
+     * @param zipFile Zip file path (precondition: must exist and be non-null).
+     * @throws IOException If the zip file is not found or the temporary directory cannot be created.
+     */
+    public Zipper(String zipFile) throws IOException {
+        // WORKAROUND: if the zip file is not found, comment out the next two lines.
+        if (resolver.getResource(zipFile) == null)
+            throw new FileNotFoundException(zipFile);
+
+        this.zipFile = zipFile;
+        this.objects = new ArrayList<>();
+
+        tempDirectory = Files.createTempDirectory("dtek0066");
+        System.out.println("Created a temp directory " + tempDirectory);
+    }
+
+    /**
+     * Deletes the temporary directory 'tempDirectory' when the object is closed.
+     *
+     * @throws IOException In case of any I/O errors.
+     */
+    @Override
+    public void close() throws IOException {
+        try (final var stream = Files.walk(tempDirectory)) {
+            stream
+                    .sorted(Comparator.reverseOrder())
+                    .map(Path::toFile)
+                    .forEach(File::delete);
+        }
+
+        System.out.println("The removed temp directory was " + tempDirectory);
+    }
+
+    /**
+     * Unzip the file 'zipFile' to the temporary directory 'tempDirectory'.
+     *
+     * @throws IOException In case of any I/O errors.
+     */
+    private void unzip() throws IOException {
+        final var destinationDir = tempDirectory.toFile();
+
+        // WORKAROUND: If the zip file is not found, change to the following
+        // try (final var inputStream = new FileInputStream(zipFile);
+        try (final var inputStream = resolver.getResourceAsStream(zipFile);
+             final var stream = new ZipInputStream(inputStream)) {
+            var zipEntry = stream.getNextEntry();
+            while (zipEntry != null) {
+                final var newFile = new File(destinationDir, zipEntry.getName());
+                final var destDirPath = destinationDir.getCanonicalPath();
+                final var destFilePath = newFile.getCanonicalPath();
+                if (!destFilePath.startsWith(destDirPath + File.separator)) {
+                    throw new IOException("Entry is outside of the target dir: " + zipEntry.getName());
+                }
+                System.out.println("Puretaan " + newFile);
+                if (zipEntry.isDirectory()) {
+                    if (!newFile.isDirectory() && !newFile.mkdirs()) {
+                        throw new IOException("Failed to create directory: " + newFile);
+                    }
+                } else {
+                    // fix for Windows-created archives
+                    final var parent = newFile.getParentFile();
+                    if (!parent.isDirectory() && !parent.mkdirs()) {
+                        throw new IOException("Failed to create directory: " + parent);
+                    }
+
+                    // write file content
+                    try (final var fos = new FileOutputStream(newFile)) {
+                        stream.transferTo(fos);
+                    }
+                }
+                zipEntry = stream.getNextEntry();
+            }
+            stream.closeEntry();
+        }
+    }
+
+    /**
+     * Executes unzipping and creates a handler for every created file.
+     *
+     * @throws IOException In case of any I/O errors.
+     */
+    public void run() throws IOException {
+        unzip();
+
+        for (final var handler : createHandlers()) {
+            E object = handler.handle();
+
+            if (object != null) {
+                objects.add(object);
+            }
+        }
+
+        var fileObjectsHandler = createFileObjectsHandler();
+        fileObjectsHandler.handle(objects);
+    }
+
+    protected List<Handler<E>> createHandlers() throws IOException {
+        try (final var stream = Files.list(tempDirectory)) {
+            return stream.map(this::createHandler).toList();
+        }
+    }
+
+    /**
+     * Creation of the Handler.
+     *
+     * @param file The file to be handerl (precondition: must exist and be non-null)
+     * @return Handler
+     */
+    protected abstract Handler<E> createHandler(Path file);
+
+    protected FileObjectsHandler<E> createFileObjectsHandler() {
+        return fileObjects -> {
+        };
+    }
+
+    /**
+     * A handler for a single file, responsible for processing
+     * an individual file.
+     */
+    protected abstract static class Handler<E> {
+        public final Path file;
+
+        /**
+         * Initializes the handler.
+         *
+         * @param file The file to be handled (precondition: must exist and be non-null)
+         */
+        public Handler(Path file) {
+            this.file = file;
+        }
+
+        /**
+         * Processes the file.
+         *
+         * @throws IOException In case of any I/O errors.
+         */
+        abstract public E handle() throws IOException;
+    }
+
+    protected interface FileObjectsHandler<E> {
+        void handle(List<E> fileObjects);
+    }
+}
+
+
+