In-Memory File System
A directory tree in memory - mkdir, touch, ls, cat - where a directory and a file
answer to the same interface for everything except "what's actually inside you." This is
Composite, played completely straight.
Requirements
Functional
- Create a file with content at a given path, creating any missing parent directories.
- Create a directory at a given path.
- List the immediate children of a directory.
- Read a file's content by path; read a directory's total size as the sum of everything under it.
- Delete a file or a directory (and everything inside it).
Non-functional
- Path resolution (
/a/b/c) should walk the tree node by node, not string-match against a flat table of every path ever created. - Computing a directory's size must not require a separate "keep this in sync" step scattered across every mutation - it should fall out of composing the sizes of its children.
Design
FileSystemNode is the interface both File and Directory implement; anything that
walks the tree (size, listing, deletion) calls the same methods on whichever kind of node
it's holding and never checks "is this a file or a directory" itself. Directory holds a
map of child name to FileSystemNode, so a subdirectory is stored exactly like a file -
just a child that happens to have children of its own.
- 1The caller gives a full path - the filesystem walks it, the caller never touches a node directly.
- 2Each missing segment along the path becomes a new Directory, created on demand.
- 3The final directory in the path gets the new File added as a named child.
- 4Asking a directory for its size and asking a file for its size use the exact same method name.
- 5A directory sums the size() of every child without knowing whether that child is a file or another directory.
Path resolution is a fold over path segments: start at the root, and for each segment ask the current directory for that named child, moving one level deeper each time - there's no separate index of "every path that exists."
Class diagram
Code
Design decisions
FileSystemNodeis the Composite interface -Fileis a leaf,Directorycomposes more nodes.size()on a file returns its content length;size()on a directory sums its children'ssize()calls, whatever those children are. Neither implementation needs to know the other exists, which is the entire point of Composite: the recursive structure lives in the interface's contract, not in a type-switching helper function.Directorystores children in a name-keyed map, not a list scanned by name on every lookup. Path resolution touches one map lookup per segment instead of a linear scan per level, which matters once a directory has more than a handful of entries.- Path resolution walks the tree, it never consults a separate path-to-node table. A flat table would need updating on every create, move, and delete and could drift from the tree it's supposed to describe; walking the tree from the root means there is exactly one source of truth for what exists.
- What's missing for a real system: creating missing parent directories on
touchis handled here as an eagermkdir -p; a real filesystem would also need move/rename (relinking a node under a new parent without recreating it, to keep it cheap regardless of subtree size) and permission checks per node, both of which are out of scope for the composite structure itself.
Common follow-ups
- How would you support
mv? Add amove(path, newParentPath)onFileSystemthat resolves both the source's current parent and the destination directory, then callsremoveChildon the old parent andaddChildon the new one with the sameFileSystemNodereference. Nothing about the node itself changes, which is exactly why Composite makes this cheap - a subtree with a million files moves in one map operation, not a million copies. - How would you cache
size()instead of recomputing it every call? GiveDirectoryacachedSizefield that every mutating method (addChild,removeChild) invalidates on itself and walks up to invalidate on every ancestor. The tradeoff is real: reads get O(1) again, but every write now touches every directory from the mutation point to the root, so it only pays off if reads vastly outnumber writes. - How do you avoid stack overflow deleting a directory with a million-deep nesting?
size()anddelete()as written recurse per level, so a pathological depth blows the call stack before it blows anything else. Rewrite the recursive walk as an explicit stack-based DFS (push children onto aDequeinstead of a real call) and the same Composite structure handles arbitrary depth without touchingFile's orDirectory's public interface. - Symlinks - how do they fit the Composite model? A
Symlinkwould be a thirdFileSystemNodeimplementation whosesize()and traversal delegate to whatever node its target path resolves to, rather than storing content itself. The interesting failure mode to name in an interview: a symlink pointing into its own subtree makessize()recurse forever, so resolution needs a visited-set guard the other two node types never needed.
Check yourself
Why does `Directory.size()` never check whether a child is a File or another Directory?