In this chapter, we will explore the design of a Unix File Search system. The goal is to design classes that represent abstractions of the key entities in a file search system, such as directories, files, and filter criteria. We’ll aim to create a clear and functional structure that captures the essential interactions between these components, ensuring the search system is intuitive and scalable.

Let’s gather the specific requirements through a simulated interview scenario.
New here? In plain terms, this is what we’re building and why — with an everyday analogy to anchor your intuition before the deep dive below.
A file-search system is like Unix find: walk a folder tree and keep only the files that match your filters — by size, name, extension — where filters snap together like LEGO (“large and .txt”). Modelling each condition as an interchangeable object keeps the search open to brand-new filters without touching old code.
Here is an example of a typical prompt an interviewer might give:
“Imagine you’re a developer trying to find specific files on a Unix system, like files owned by a user, or text files matching a pattern, buried deep in a directory structure. You run a search command, specify your criteria, and the system returns matching files quickly. Behind the scenes, it’s recursively traversing directories, evaluating file attributes, and applying your filters efficiently. Let’s design a Unix File Search system that handles this process.”
Here is an example of how a conversation between a candidate and an interviewer might unfold:
Candidate: What attributes does the find command use to search for files?
Interviewer: It could be based on criteria like size, file type, filename, and owner.
Candidate: Does it need to handle directories?
Interviewer: Yes, directories are considered as files too, with a distinct file type.
Candidate: What types of comparisons does the command support?
Interviewer: That depends on the type of attribute. For strings, we support ‘equals’ and ‘regex match’. For numbers, we support ‘greater than’, ‘equals’, and ‘less than’.
Candidate: Can we combine multiple criteria, even on the same attribute?
Interviewer: Yes, with multiple criteria, using ‘and’, ‘or’, and ‘not’ conditions.
Candidate: I assume we’re designing a system to search a directory and its sub-directories, returning files that match the given conditions.
Interviewer: Yes, that's a fair assumption.
With the requirements for our Unix File Search system in hand, let’s see them in action through some real-world command-line searches. These examples will show what the system needs to handle and set the stage for designing our classes:
Based on the questions and answers, the following functional requirements can be identified:
Below are the non-functional requirements:
With these requirements established, let’s move on to identifying the core objects that will bring this system to life.
Now that we’ve seen how Unix file searches work, it’s time to design a system that can handle them. Let’s break it down into core objects, each with a clear role, to create a file search system that’s both modular and easy to maintain. Here’s what we’ll need:
Design choice: The File object represents files and directories as a single entity. This enables FileSearch to perform consistent traversal and evaluation. This design aligns with the Unix principle that treats everything as a file for uniform handling.
Alternative approach: We could merge FileSearchCriteria and Predicate into a single class, embedding the matching logic directly in FileSearch. This simplifies the design by removing one layer but reduces modularity, as the search logic would be tightly coupled to condition evaluation, making it harder to swap criteria. For instance, switching the condition from "size > 10" to "owner = 'bob'" would require updating FileSearch.
We’ve mapped out the core objects, such as File and FileSearch, for our Unix File Search system. Now, let’s define their classes, pinning down their roles and methods to keep everything clear and modular.
To model a filesystem for searching, we need a way to represent files and directories. Rather than relying on a standard library like Java’s java.io.File, we define a custom File class as the core entity, capturing key attributes and supporting hierarchical traversal. It’s paired with a FileAttribute enum for attributes used in search conditions.
Below is the representation of this class and the enum.
Design choice: We define FileAttribute as an enum to provide a fixed, type-safe set of attributes (e.g., size, owner) for search conditions. This ensures that only valid, predefined attributes are used when evaluating files, preventing runtime errors from invalid attribute names. It also supports scalability: adding a new attribute, such as modification time, requires simply extending the enum, keeping the system extensible without altering existing logic.
The FileSearch class is responsible for traversing the file system from a given File, using a FileSearchCriteria object to select matching files and return them. By separating traversal from filtering logic, the design remains modular, maintainable, and easy to extend. The UML diagram below illustrates this structure.
The FileSearchCriteria class decides which files match our search by connecting FileSearch to Predicate. It tells FileSearch what qualifies as a match, using Predicate to check each File against the conditions.
Here is the representation of this class.
Design choice: We designed FileSearchCriteria to work alongside Predicate, allowing it to evaluate whether a file meets the search conditions without handling all the logic itself. This keeps responsibilities clean and modular. For example, FileSearchCriteria delegates to a Predicate to check if a file’s size is greater than 10 or if the owner is “bob.” This delegation enables flexibility. We can change or combine filtering logic (like checking different attributes or using complex conditions) without modifying either FileSearch or FileSearchCriteria. By decoupling the search traversal from condition evaluation, we preserve the separation of concerns and make the system easier to extend and maintain.
A key part of our design is the ability to define conditions that determine whether a file should be included in the search results. These conditions can range from simple to complex. For example, we might want files whose names follow a regular expression like report.*, or files with sizes exceeding 10 bytes. To manage this, we introduce the Predicate interface as the foundation for evaluating files. It defines a single method that takes a File object and returns a boolean: true if the file satisfies the condition, false otherwise.
For straightforward conditions, we implement the SimplePredicate class. This concrete class evaluates a single file attribute, such as size or owner, against a specified value using a comparison operator (like greater than or equal). For instance, it can check "is the size bigger than 10?" or "is the owner 'bob'?" by leveraging the FileAttribute enum and a ComparisonOperator instance. The UML diagram below illustrates how these pieces fit together.
The ComparisonOperator interface defines a contract for comparing a file’s attribute value (like size or name) to an expected value, answering questions like "is the size greater than 10?" or "does the filename match the pattern log.*?" It declares a method that takes two values (the attribute’s actual value and the target value), and returns a boolean indicating whether the comparison holds. We implement this interface with concrete classes, such as:
This interface-based design allows SimplePredicate to delegate comparisons to specialized classes like EqualsOperator or RegexMatchOperator, each optimized for its operation, enabling precise and efficient file filtering in FileSearchCriteria.
The UML diagram below shows how this structure comes together.
Alternative approaches: We could represent operations with strings such as "equals" or ">". This simplifies initial implementation but shifts validation to runtime. Each string must be parsed and mapped to a comparison function, which increases execution time and risks runtime exceptions if an invalid operator (e.g., "equals") is left unchecked.
Another option is to use enums like EQUALS or GREATER_THAN to represent comparison operations. This makes the code safer and faster because the operations are checked at compile time, not at runtime. However, if you want to add a new operation, like case-insensitive equality for owner names, you would need to change the enum itself. In contrast, with the interface-based approach, you can just create a new class for the new operation without touching existing code.
With SimplePredicate and its ComparisonOperator implementations in place, we can already test files against single conditions like size > 10 or owner = 'alice'. But real-world searches often demand more, combining multiple conditions with logical operators. To tackle this, we use the Composite design pattern, enabling us to build complex predicates from simpler ones.
Note: To learn more about the Composite Pattern and its common use cases, refer to the Further Reading section at the end of this chapter.
Consider a search like this:
Find files where ((size > 10 and size < 1000 and owner = "alice") or (size > 1000 and !(filename matches /prefix.*/))).
If we label each simple condition:
This structure uses "and," "or," and "not" operators, with brackets indicating the order of nested evaluations. This tree-like hierarchy needs a systematic way to evaluate files.
To handle this, we define the CompositePredicate interface, extending Predicate, with concrete implementations: AndPredicate, OrPredicate, and NotPredicate. These classes compose multiple predicates into a single unit, evaluated recursively:
In our example:
This structure, rooted in CompositePredicate, delivers a boolean result to FileSearchCriteria efficiently by distributing evaluation across the tree, avoiding redundant checks.
The UML diagram below illustrates this recursive composition.
With all classes defined, let’s review how they fit together in the complete class diagram.
Having built our classes, from the hierarchical File structure to the intricate predicate logic, we’re ready to see the complete system in a UML class diagram below. The detailed methods and attributes are skipped to make the diagram more readable.
Now that the design is in place, we can move on to implementing the core components: defining files and their attributes, applying conditions to evaluate matches, and performing the actual file search across the directory structure.
The File class models both files and directories in our search system, encapsulating attributes such as size, name, owner, and a boolean flag indicating directory status. It maintains a hierarchical structure through a set of child File objects, which represent the contents of subdirectories. Built for immutability, it offers no setters. Attributes are set at construction and remain unchanged, ensuring data consistency throughout the search process.
Tied to this, the FileAttribute enum defines the searchable properties, such as SIZE or OWNER, enabling SimplePredicate to extract the correct value (e.g., size for a "size > 10" condition) during evaluation.
Below is the code implementation:
// Represents a file or directory in the file system
// Contains basic file attributes and supports hierarchical structure
public class File {
private final boolean isDirectory;
private final int size;
private final String owner;
private final String filename;
// Set of directory entries (files and subdirectories)
private final Set<File> entries = new HashSet<>();
// Creates a new file with the specified attributes
public File(
final boolean isDirectory,
final int size,
final String owner,
final String filename) {
this.isDirectory = isDirectory;
this.size = size;
this.owner = owner;
this.filename = filename;
}
// Extracts the value of a specified file attribute
public Object extract(final FileAttribute attributeName) {
switch (attributeName) {
case SIZE -> {
return size;
}
case OWNER -> {
return owner;
}
case IS_DIRECTORY -> {
return isDirectory;
}
case FILENAME -> {
return filename;
}
}
throw new IllegalArgumentException("invalid filter criteria type");
}
// Adds a file or directory entry to this directory
public void addEntry(final File entry) {
entries.add(entry);
}
// getter methods omitted for brevity
}
// Represents the different attributes that can be checked for a file
public enum FileAttribute {
IS_DIRECTORY,
SIZE,
OWNER,
FILENAME
}
The Predicate interface defines the contract for evaluating whether a File satisfies a search condition, serving as the cornerstone of our system’s filtering logic. It declares a single method, isMatch, which accepts a File instance and returns a boolean: true if the file meets the condition, false otherwise. Here’s its implementation:
// Base interface for all file search predicates
public interface Predicate {
// Checks if the given file matches the search condition
boolean isMatch(final File inputFile);
}
This method powers FileSearchCriteria by providing the yes-or-no decision needed to filter files during traversal. Its abstract nature ensures flexibility, supporting everything from simple checks like "size > 10" to complex combinations, laying the groundwork for both SimplePredicate and CompositePredicate implementations.
The ComparisonOperator interface specifies a contract for comparing a file’s attribute value to an expected value, enabling precise condition checks, such as "is the size greater than 10?" or "does the filename match log.*?". It defines an isMatch method that takes two parameters (the attribute’s actual value and the target value) and returns a boolean indicating if the comparison holds.
We use generics (<T>) to enforce type safety, ensuring that numeric attributes (e.g., Double for size) are compared only with numbers, and string attributes (e.g., String for names) are compared only with strings.
Below is the implementation of this interface:
// Base interface for all comparison operations in the file search system
public interface ComparisonOperator<T> {
boolean isMatch(final T attributeValue, final T expectedValue);
}
Concrete implementations, such as EqualsOperator, GreaterThanOperator, LessThanOperator, and RegexMatchOperator, encapsulate the logic for equality, ordering, and pattern matching, each tailored to its respective type. These are shown below.
// Implements exact equality comparison between values
public class EqualsOperator<T> implements ComparisonOperator<T> {
@Override
public boolean isMatch(final T attributeValue, final T expectedValue) {
return Objects.equals(attributeValue, expectedValue);
}
}
// Implements greater than comparison for numeric values
class GreaterThanOperator<T extends Number> implements ComparisonOperator<T> {
@Override
public boolean isMatch(final T attributeValue, final T expectedValue) {
return Double.compare(attributeValue.doubleValue(), expectedValue.doubleValue()) > 0;
}
}
// Implements less than comparison for numeric values
class LessThanOperator<T extends Number> implements ComparisonOperator<T> {
@Override
public boolean isMatch(final T attributeValue, final T expectedValue) {
return Double.compare(attributeValue.doubleValue(), expectedValue.doubleValue()) < 0;
}
}
// Implements regular expression pattern matching for string values
public class RegexMatchOperator<T extends String> implements ComparisonOperator<T> {
@Override
public boolean isMatch(final T attributeValue, final T expectedValue) {
final Pattern p = Pattern.compile(expectedValue);
return p.matcher(attributeValue).matches();
}
}
The isMatch method takes an actual value (from a File attribute) and an expected value (set in SimplePredicate), and returns true if they satisfy the operator’s rule.
Implementation choice: We leverage generics (<T>) in this design to enforce type safety at compile time, ensuring that attribute values and expected values align in type, such as Double for size or String for filename, making the code robust and readable.
The SimplePredicate class implements the Predicate interface to test a File against a single condition, such as whether its size exceeds 10 or its owner is "bob". Designed with generics (<T>), it pairs a FileAttribute enum value (e.g., SIZE, OWNER) with a ComparisonOperator<T> and an expected value of type T to define the check.
Here’s how it’s structured:
// A basic predicate that compares a file attribute with an expected value
public class SimplePredicate<T> implements Predicate {
// The name of the file attribute to check
private final FileAttribute attributeName;
// The operator to use for comparison (equals, contains, greater than, etc.)
private final ComparisonOperator<T> operator;
// The expected value to compare against
T expectedValue;
// Creates a new simple predicate with the specified attribute, operator, and
// expected value
public SimplePredicate(
final FileAttribute attributeName,
final ComparisonOperator<T> operator,
final T expectedValue) {
this.attributeName = attributeName;
this.operator = operator;
this.expectedValue = expectedValue;
}
@Override
public boolean isMatch(final File inputFile) {
// Extract the actual value of the attribute from the file
Object actualValue = inputFile.extract(attributeName);
// Check if the actual value is of the correct type
if (expectedValue.getClass().isInstance(actualValue)) {
// Perform the comparison using the specified operator
return operator.isMatch((T) actualValue, expectedValue);
} else {
return false;
}
}
}
This implementation of SimplePredicate ensures type safety and modularity, allowing FileSearchCriteria to apply precise, single-attribute filters across the filesystem traversal.
The CompositePredicate interface extends Predicate to combine multiple predicates into complex conditions, such as "size > 10 and owner = 'bob'". We follow the Composite design pattern, which organizes predicates into a tree where simple ones (like SimplePredicate) act as leaves and combinations (like AndPredicate) act as nodes, all sharing the Predicate interface. This lets us evaluate them recursively, treating single and combined conditions the same way.
Here’s the code:
public interface CompositePredicate extends Predicate {
// This interface is intentionally empty as it serves as a marker
// to identify predicates that combine multiple other predicates (AND, OR, NOT)
}
// Implements logical AND operation between multiple predicates
public class AndPredicate implements CompositePredicate {
// List of predicates that must all match for this predicate to match
private final List<Predicate> operands;
// Creates a new AND predicate with the specified predicates
public AndPredicate(final List<Predicate> operands) {
this.operands = operands;
}
// Checks if the given file matches ALL predicates
@Override
public boolean isMatch(final File inputFile) {
return operands.stream().allMatch(predicate -> predicate.isMatch(inputFile));
}
}
// Implements logical OR operation between multiple predicates
public class OrPredicate implements CompositePredicate {
// List of predicates, at least one of which must match
private final List<Predicate> operands;
// Creates a new OR predicate with the specified predicates
public OrPredicate(final List<Predicate> operands) {
this.operands = operands;
}
@Override
public boolean isMatch(final File inputFile) {
return operands.stream().anyMatch(predicate -> predicate.isMatch(inputFile));
}
}
// Implements logical NOT operation on a predicate
public class NotPredicate implements CompositePredicate {
// The predicate to negate
private final Predicate operand;
// Creates a new NOT predicate with the specified predicate to negate
public NotPredicate(final Predicate operand) {
this.operand = operand;
}
@Override
public boolean isMatch(final File inputFile) {
return !operand.isMatch(inputFile);
}
}
The FileSearchCriteria class acts as a bridge between FileSearch and the predicate-based conditions that determine which files match, delegating the evaluation to a Predicate, whether a SimplePredicate or CompositePredicate. Here’s its implementation:
// Wrapper class that encapsulates a search condition for file matching
public class FileSearchCriteria {
// The predicate that defines what makes a file match
private final Predicate predicate;
// Constructor that takes a predicate defining the criteria
public FileSearchCriteria(final Predicate predicate) {
this.predicate = predicate;
}
// Checks if the given file matches the search criteria
public boolean isMatch(final File inputFile) {
return predicate.isMatch(inputFile);
}
}
This design centers on the isMatch method, which invokes the Predicate’s logic to assess a File and returns true for matches, such as "size > 10" with SimplePredicate or "size > 10 and owner = 'bob'" with CompositePredicate.
Implementation choice: We chose to wrap the Predicate in FileSearchCriteria to keep FileSearch focused solely on filesystem traversal and isolate condition evaluation to a separate, reusable layer.
Alternate implementation: Instead of using FileSearchCriteria, FileSearch could directly invoke the Predicate's logic to evaluate files. However, this would tightly couple traversal and filtering logic, making it harder to modify or extend conditions independently.
The FileSearch class orchestrates the search across the filesystem, starting from a root File and leveraging FileSearchCriteria to identify matching files through recursive traversal. It employs a stack-based approach to explore directories efficiently. Here’s the implementation:
// Main class responsible for performing file system searches
public class FileSearch {
// Performs a recursive search through the file system starting from root
// Returns a list of files that match the given criteria
public List<File> search(final File root, final FileSearchCriteria criteria) {
// List to store matching files
final List<File> result = new ArrayList<>();
// Stack to handle recursive traversal without actual recursion
final ArrayDeque<File> recursionStack = new ArrayDeque<>();
// Start with the root directory
recursionStack.add(root);
// Continue until we've processed all files
while (!recursionStack.isEmpty()) {
// Get the next file to process
File next = recursionStack.pop();
// Check if the file matches our criteria
if (criteria.isMatch(next)) {
result.add(next);
}
// Add all directory entries to the stack for processing
for (File entry : next.getEntries()) {
recursionStack.push(entry);
}
}
return result;
}
}
This search method begins by initializing a result list and an ArrayDeque stack with the root File. It then processes each file by popping it from the stack, evaluating it with the criteria.isMatch, and adding matches to the result. For directories, it pushes all entries onto the stack, ensuring every file is visited in a depth-first manner. The returned List<File> contains all matches, mutable for flexibility in downstream use, by the client code.
Implementation choice: We opted for a stack over recursive calls to prevent stack overflow in deep filesystems, balancing efficiency with robustness. Alternatively, a recursive method could work for smaller structures, but it risks failure on large or deeply nested directories.
Now that the basic design is complete, the interviewer might ask you some deep dive questions. Let’s check out some of these.
After implementing our classes, it’s a good idea to verify the end-to-end logic. The UNIX file search problem is abstract, so we create a test case to demonstrate how it works.
Here’s a test case for the condition "non-directories owned by 'ge.*'":
public class FileSearchTest {
@Test
public void testFileSearch() {
// Create a root directory and two files with different owners
final File root = new File(true, 0, "adam", "root");
final File a = new File(false, 2000, "adam", "a");
final File b = new File(false, 3000, "george", "b");
// Add files to the root directory
root.addEntry(a);
root.addEntry(b);
// Search criteria: Find non-directory files owned by users matching "ge.*"
final FileSearchCriteria criteria =
new FileSearchCriteria(
new AndPredicate(
List.of(
new SimplePredicate<>(
FileAttribute.IS_DIRECTORY,
new EqualsOperator<>(),
false),
new SimplePredicate<>(
FileAttribute.OWNER,
new RegexMatchOperator<>(),
"ge.*"))));
// Execute the search and get results
final FileSearch fileSearch = new FileSearch();
final List<File> result = fileSearch.search(root, criteria);
// Verify that only one file matches the criteria
assertEquals(1, result.size());
// Verify that the matching file is "b"
assertEquals("b", result.get(0).getFilename());
}
}
A runnable search over a small directory tree, with composable filters (by extension, by size) that snap together with AND — the Composite / criteria idea from the design, so new filters plug in without touching old code.
from abc import ABC, abstractmethod
class File:
def __init__(self, name, size=0, is_dir=False, children=None):
self.name = name
self.size = size
self.is_dir = is_dir
self.children = children if children is not None else []
# ---------- Filters snap together like building blocks ----------
class Filter(ABC):
@abstractmethod
def matches(self, file): ...
class ExtensionFilter(Filter):
def __init__(self, ext):
self.ext = ext
def matches(self, file):
return file.name.endswith("." + self.ext)
class MinSizeFilter(Filter):
def __init__(self, min_size):
self.min_size = min_size
def matches(self, file):
return file.size >= self.min_size
class AndFilter(Filter):
def __init__(self, filters):
self.filters = filters
def matches(self, file):
return all(f.matches(file) for f in self.filters)
def search(node, path, filt, results):
full = path + "/" + node.name
if not node.is_dir and filt.matches(node):
results.append(full)
for child in node.children:
search(child, full, filt, results)
def find(root, filt):
results = []
search(root, "", filt, results)
return results
def join(items):
return "[" + ", ".join(items) + "]"
def main():
tree = File("root", is_dir=True, children=[
File("a.txt", 100),
File("b.log", 2000),
File("sub", is_dir=True, children=[
File("c.txt", 5000),
File("d.jpg", 300),
]),
])
print("ext == txt -> " + join(find(tree, ExtensionFilter("txt"))))
print("size >= 1000 -> " + join(find(tree, MinSizeFilter(1000))))
print("txt AND size >= 1000 -> "
+ join(find(tree, AndFilter([ExtensionFilter("txt"), MinSizeFilter(1000)]))))
if __name__ == "__main__":
main()#include <iostream>
#include <string>
#include <vector>
struct File {
std::string name;
int size;
bool isDir;
std::vector<File*> children;
File(std::string name, int size) : name(name), size(size), isDir(false) {}
File(std::string name, bool isDir, std::vector<File*> children)
: name(name), size(0), isDir(isDir), children(children) {}
};
// ---------- Filters snap together like building blocks ----------
class Filter {
public:
virtual bool matches(File* file) = 0;
virtual ~Filter() {}
};
class ExtensionFilter : public Filter {
std::string ext;
public:
ExtensionFilter(std::string ext) : ext(ext) {}
bool matches(File* file) override {
std::string suffix = "." + ext;
if (file->name.size() < suffix.size()) return false;
return file->name.compare(file->name.size() - suffix.size(), suffix.size(), suffix) == 0;
}
};
class MinSizeFilter : public Filter {
int minSize;
public:
MinSizeFilter(int minSize) : minSize(minSize) {}
bool matches(File* file) override { return file->size >= minSize; }
};
class AndFilter : public Filter {
std::vector<Filter*> filters;
public:
AndFilter(std::vector<Filter*> filters) : filters(filters) {}
bool matches(File* file) override {
for (Filter* f : filters) if (!f->matches(file)) return false;
return true;
}
};
static void search(File* node, const std::string& path, Filter* filt, std::vector<std::string>& results) {
std::string full = path + "/" + node->name;
if (!node->isDir && filt->matches(node)) results.push_back(full);
for (File* child : node->children) search(child, full, filt, results);
}
static std::vector<std::string> find(File* root, Filter* filt) {
std::vector<std::string> results;
search(root, "", filt, results);
return results;
}
static std::string join(const std::vector<std::string>& items) {
std::string s = "[";
for (std::size_t i = 0; i < items.size(); ++i) { if (i) s += ", "; s += items[i]; }
return s + "]";
}
int main() {
File* tree = new File("root", true, {
new File("a.txt", 100),
new File("b.log", 2000),
new File("sub", true, {
new File("c.txt", 5000),
new File("d.jpg", 300)
})
});
ExtensionFilter txt("txt");
MinSizeFilter big(1000);
std::vector<Filter*> both = { new ExtensionFilter("txt"), new MinSizeFilter(1000) };
AndFilter andFilter(both);
std::cout << "ext == txt -> " << join(find(tree, &txt)) << "\n";
std::cout << "size >= 1000 -> " << join(find(tree, &big)) << "\n";
std::cout << "txt AND size >= 1000 -> " << join(find(tree, &andFilter)) << "\n";
return 0;
}import java.util.ArrayList;
import java.util.List;
class File {
final String name;
final int size;
final boolean isDir;
final List<File> children;
File(String name, int size) { this(name, size, false, new ArrayList<>()); }
File(String name, boolean isDir, List<File> children) { this(name, 0, isDir, children); }
File(String name, int size, boolean isDir, List<File> children) {
this.name = name; this.size = size; this.isDir = isDir; this.children = children;
}
}
// ---------- Filters snap together like building blocks ----------
interface Filter {
boolean matches(File file);
}
class ExtensionFilter implements Filter {
private final String ext;
ExtensionFilter(String ext) { this.ext = ext; }
public boolean matches(File file) { return file.name.endsWith("." + ext); }
}
class MinSizeFilter implements Filter {
private final int minSize;
MinSizeFilter(int minSize) { this.minSize = minSize; }
public boolean matches(File file) { return file.size >= minSize; }
}
class AndFilter implements Filter {
private final List<Filter> filters;
AndFilter(List<Filter> filters) { this.filters = filters; }
public boolean matches(File file) {
for (Filter f : filters) if (!f.matches(file)) return false;
return true;
}
}
public class Main {
static void search(File node, String path, Filter filt, List<String> results) {
String full = path + "/" + node.name;
if (!node.isDir && filt.matches(node)) results.add(full);
for (File child : node.children) search(child, full, filt, results);
}
static List<String> find(File root, Filter filt) {
List<String> results = new ArrayList<>();
search(root, "", filt, results);
return results;
}
static String join(List<String> items) { return "[" + String.join(", ", items) + "]"; }
public static void main(String[] args) {
File tree = new File("root", true, new ArrayList<>(List.of(
new File("a.txt", 100),
new File("b.log", 2000),
new File("sub", true, new ArrayList<>(List.of(
new File("c.txt", 5000),
new File("d.jpg", 300)))))));
System.out.println("ext == txt -> " + join(find(tree, new ExtensionFilter("txt"))));
System.out.println("size >= 1000 -> " + join(find(tree, new MinSizeFilter(1000))));
System.out.println("txt AND size >= 1000 -> "
+ join(find(tree, new AndFilter(List.of(new ExtensionFilter("txt"), new MinSizeFilter(1000))))));
}
}Sample run — identical across all three languages:
ext == txt -> [/root/a.txt, /root/sub/c.txt]
size >= 1000 -> [/root/b.log, /root/sub/c.txt]
txt AND size >= 1000 -> [/root/sub/c.txt]
With the UNIX file search system fully implemented and tested, it’s time to step back and consider what we’ve achieved. This chapter began by gathering requirements through a structured dialogue, then progressed to defining core objects, crafting their class structure, and coding the essential components.
The system's maintainability and extensibility are ensured by the clear division of responsibilities among the classes: File, FileSearch, FileSearchCriteria, and Predicate, which respectively represent files, traverse directories, evaluate conditions, and define match logic. Our choices, such as separating FileSearch from FileSearchCriteria and using generics in ComparisonOperator, improve scalability and maintain type safety throughout processes. We could have merged FileSearchCriteria with Predicate into one class for a tighter initial design, but this would blur their distinct roles, making it harder to update or swap condition logic without affecting traversal.
Congratulations on getting this far! Now give yourself a pat on the back. Good job!
This section gives a quick overview of the design patterns used in this chapter. It’s helpful if you’re new to these patterns or need a refresher to better understand the design choices.
Composite is a structural pattern that lets you organize objects into tree structures and then handle these structures as if they were individual objects.
Problem
Imagine you have two types of objects: Files and Folders. A Folder can contain several Files as well as many smaller Folders and so on. Say you decide to create a search system that uses these classes. Searches could involve simple Files on their own, as well as Folders packed with Files, and other Folders. How would you find all items matching a specific condition, like "size > 10," across this structure?
Solution
The Composite pattern suggests that we work with Files and Folders through a common interface that declares a method for checking conditions.
Here’s how this method works:
Here’s a simple diagram showing the Composite pattern for Files and Folders:
Item is the common interface that both File and Folder use. The advantage is that we can treat all objects, File or nested Folder, the same through the common interface, letting them pass the check down the tree without knowing their types.
When to use
The Composite design pattern is useful in scenarios where: