In
#2727011: [policy, no patch] Private vs protected, and the role of inheritance β
, I talked a lot about how I think our classes and interfaces are too big.
(As long as this is the case, there is an argument preventing us from using "private" instead of "protected".)
I want to propose a vision and strategy to tackle this problem.
I think the key towards smaller classes and interfaces is "atomic behavior objects".
Instead of creating classes for "domain objects" (if I am using this term right) or "manager"-type broad-purpose services, our focus should be to identify standalone behaviors, and create interfaces and classes based on those.
(I don't say to remove all domain objects or services, just to change the focus)
Atomic behavior objects
How do such "behavior object" classes and interfaces look like?
- The starting point is always a single method. Additional methods should be the exception.
Additional methods should only be added, if they are relevant to every conceivable implementation of the main method.
- The behavior doesn't have to be globally useful or relevant. E.g. its only application might be within a specific algorithm, so we would mark the interface as "@internal". But it has to be complete and self-contained.
- Such classes typically don't have getters. All the data they contain should only be used for the implementation of the main behavior method.
- They should be designed as immutable, so won't have setters. Perhaps some of them can have "withers" (methods that return a modified clone). But none of those would be part of the interface.
- The constructor must always provide a fully valid and usable instance.
- The constructor expects parameters in a format that is relevant to the main behavior. E.g. not a settings array, but specific values that will be saved in private properties, and mostly be used as-is to run the behavior. The constructor can do some basic sanity checks, but that's it.
- Static factory methods ("named constructors") can be used to extract constructor arguments from configuration arrays, if necessary.
- Inheritance should be avoided, or reduced to a minimum. Most classes can/should be either final or abstract.
- Properties should only be accessible within the same file. Which means they should be private.
- Base classes should be abstract, with private properties, and well-defined extension points.
Naming
One challenge is to give such classes and their interfaces good names.
In my own projects, I use a naming scheme like "$vendor\$package\$category\{$what}_{$how}"
for classes (with underscore), and "$vendor\$package\$category\[$what}Interface"
for interfaces (without underscore). This way, the class alias always gives sufficient information to understand its purpose, without looking at the class imports.
In our official coding standards we do not use underscores in class names, so it can either be "$namespace\[$what}{$how}"
or "$namespace\{$how}{$what}"
. Putting the $what
first makes multiple implementations align nicely in a directory.
The name of the interface (the $what part) should describe the behavior itself, not where or how or why it is meant to be used.
We could have one directory/namespace per behavior type (one interface plus one or more implementations), or we could have different behavior types live together in one namespace.. up for debate.
Callables/functional?
Someone could ask: Why not functional programming, using closures/callables instead of those one-method objects?
Some simple reasons:
- PHP does not support signature type checks for callable parameters. The callable type hint allows any type of callable.
- Closures cannot be cleanly organized in class files, addressed by global names, and reused. Functions (outside of classes, or static) don't have constructor parameters (the "use" part of closures).
"Plugins"?
Someone else might say: Plugins!
On the one hand, plugins (in D8) usually are designed to implement one specific behavior. Which is great!
Unfortunately, they typically do a number of things which is not relevant to this main behavior:
- They can tell about their own plugin id.
- They can tell about their own plugin definition.
- Some of them manage their own configuration, and show a configuration form.
Value objects?
Value objects are another category of nicely-behaving objects, if implemented correctly.
I don't mind having those too. But I am not talking about them here.
Examples
We already have some "behavior objects" in core:
- Drupal\Core\Controller\TitleResolverInterface
- Drupal\Core\Render\Placeholder\PlaceholderStrategyInterface
I find the name and the doc descriptions somewhat misleading. But it does qualify.
- Drupal\Core\PathProcessor\OutboundPathProcessorInterface
Again, confusing class name, and a doc description that just parrots the class name.
- Drupal\aggregator\FeedInterface\ParserInterface
- Drupal\Core\Extension\InfoParserInterface
I like this one, because it is such a clearly defined purpose.
- Drupal\Core\Asset\AssetDumperInterface
Then there is "renderkit", which is full of behavior objects.
https://cgit.drupalcode.org/renderkit/tree/src
Some of them might be overkill, but it illustrates the idea.
Some "behaviors" currently dwell in manager-type services.
E.g. ThemeManager and ModuleHandler each have an ::alter() method with the same signature, which could easily live in an AltererInterface.
ThemeManager::render() could live in a ThemeRendererInterface or ThemeHookRendererInterface (something like that).
Why does it matter?
Individual small classes and interfaces are easier to read, they fit more easily into one chunk of your brain.
They make it much easier to implement compositional patterns. E.g. a decorator for a one-method interface does not need pass-through implementations for all other methods.
Classes and interfaces with only one or few methods reduce or eliminate the need for inheritance. We can use "final" and "private" a lot more.
Short classes and methods reduce the number of code lines from which one local variable or private object member can be seen or manipulated.
Tiny interfaces with one or two methods allow for fine-grained dependency injection.
Tiny classes are easier to be made immutable.
Huge and complex classes, on the other hand, are harder to read and obfuscate bugs, bottlenecks and vulnerabilities.
Splitting things up into smaller classes and interfaces can have a cost, though:
- More clutter in the filesystem.
- Deeper stack traces.
- Deeper object composition trees.
- Some classes and interfaces which only have a local relevance, and no higher-level meaning.
- More autoload calls, perhaps higher memory footprint for PHP opcache.
On the other hand, having things nicely split up often allows for some interesting optimizations.
This needs to be evaluated as we go.