Make every query an object so criteria compose with and, or, not; make the tree uniform so traversal never asks file-vs-directory.
Why a parameter list fails
find(root, name=, size=, extension=): each optional guard is an implicit AND, nothing else.- Can express “over 10 MB AND ends
.log”, never “over 10 MB OR ends.log, and NOT under/tmp”. - With 5 yes/no facts, a flag list reaches
2^5 = 32conjunctions; the full boolean space is2^32(~4.3 billion). OR, NOT, and mixtures are all outside it.
The two patterns
- Specification (query side): each criterion is a
Filterwith onematches(node) -> bool; combinators returnFilter, so a combination is usable anywhere a single one is. A query is a tree, not a list. - Composite (tree side):
File(leaf) andDirectory(branch) share oneNodeinterface (path,size()), so traversal never asks which it holds.
Filter algebra
Filter.__and__ → And,__or__ → Or,__invert__ → Not: these are the hooks for&,|,~, so queries read(a | b) & ~c.And=all(...),Or=any(...),Not= negation; each is itself aFilterholdingFilters.- Each concrete criterion (
NameMatches,Extension,LargerThan,UnderPath,IsFile) = one constructor + onematches; none mentions traversal or another criterion. - Adding a criterion = new class, no caller edits (open-closed).
And
/ \
Or Not
/ \ \
>10MB .log under /tmp
Gotchas:
&binds tighter than|, soa | b & c=a | (b & c); parens in(a | b) & ~care required.&/|do NOT short-circuit like theand/orkeywords;a | balways builds both operands.
The tree (Composite)
Node:@abstractmethod size(),@property path(walks parent chain, read without()).File.size()returns bytes;Directory.size()sums children.Directory.addis the only place to keep a tree a tree: rejects non-Node, refuses re-parenting (c.parent is not None), refuses cycles (walk up from new parent looking for the child).walk= pre-order generator;yieldnode, recurse into directories, optionally prune withdescend.
The costs it charges
| Decision | Cost |
|---|---|
| Composite uniform interface | Leaf questions on branches have no honest answer: Directory.size() sums subtree (real find tests the dir’s own block); mtime = entry-list change, not newest child |
matches depends on outside state | ModifiedWithin(clock) uses dependency injection; no longer a pure function, so caching is unsound and same query varies by time |
| Streaming (generator) | First hit in µs at constant memory, but loses len()/sorted() without buffering, single-pass, deferred exceptions and mutation, yield from depth (one frame per level) |
Match vs prune
- Same predicate, two positions:
where(matcher) visits then discards;descend(pruner) never enters the subtree. Same answer, different work. - Negation is a property of a predicate in a position:
IsFile()asdescendyields only the root (no directory is a file, so none is entered). - Inferring pruning from the match expression = a query optimiser; correct only for monotone criteria (
UnderPathyes,LargerThanno). Keep the two arguments separate.
What the design assumes
- Signature
matches(node) -> boolcommits to three things: match decidable from one node alone (no “ten largest”, duplicates, “newer than sibling”); boolean not a score (no ranking); opaque code not inspectable data (can’t push into an index). - Relax any one → you’re building an index (
locate), a stream pipeline, or a ranker instead. - One line: a query earns its object the moment it needs OR or NOT; after that, the next criterion is a new class with no callers to break.