This article is also available in Serbo-Croatian
This article is also available in Dutch
There has been much discussion of late in Drupal about Object-Oriented Programming. That's not really surprising, given that Drupal 7 is the first version that has really tried to use objects in any meaningful way (vis, as something other than arrays that pass strangely). However, too much of the discussion has boiled down to "OMG objects are inflexible so they're evil!" vs. "OMG objects are cool, yay!" Both positions are harmfully naive.
It is important for us to take a step back and examine why one particular programming paradigm is useful, and to do that we must understand what we mean by "useful".
Programming paradigms, like software architecture, have trade-offs. In fact, many of the same methods for comparing architectural designs apply just as well to language design. To do that, though, we need to take a step back and look at more than just PHP-style objects.
Warning: Hard-core computer science action follows. If you're a coder, I recommend getting a cup of
$beverage before continuing, as it could take a bit to digest although I've tried to simplify it as much as possible. There's fairly little Drupal-specific stuff here so hopefully it should be useful to any PHP developer.
Approaches to programming
Every programming language is, fundamentally, a way of encoding logic. There are different was to encode logic in such a way that a computer can process it, and every language has a slightly different spin on the subject. In the abstract, though, we can identify several general paradigms, a number of which are relevant to us as PHP developers. (The Wikipedia links below have far more detail than I can go into here.)
- Procedural programming
- Procedural is frequently the first programming style taught. A program is organized into "procedures" (aka functions or subroutines) that then operate on data. The code is generally written in an imperative fashion: Do X, then do Y, then do Z. Those subroutines frequently have side-effects; that is, some program state has permanently changed in a way that lasts beyond the life of the subroutine.
- Functional programming
- Functional programming is as old as procedural programming. In fact, the two approaches were the original schism in programming conceptualization. In purely functional programming one does not write a set of steps for the program to follow. Instead, one writes mathematical functions that relate to each other. That has a number of important attributes: Most notably, purely functional programs are incapable of side-effects. Functions have no state of their own, and in fact once a variable has a value it may not be changed again. Ever. The output of a function depends exclusively on its explicit inputs. Generally, functional languages also treat functions as first-class objects along-side other more familiar variables like ints, strings, and so forth.
- Declarative programming
- Declarative languages avoid specifying how the computer should do something in favor of saying what it should do, somehow. This is extremely powerful for simplifying common tasks, but can make developing new-and-innovative capabilities harder; they still need to be translated from the declarative form into an executable approach somewhere. SQL itself is the most obvious example for web developers, but there are many more. Even some markup languages can, arguably, be considered declarative programming. (See also: HTML5 or SVG)
- Classic Object-Oriented programming
- In class-based OO (or "Classic" as I tend to call it), rather than logic being encapsulated into simple subroutines that operate on arbitrary data passed to them logic and data are bundled together into "objects". These objects are treated by the outside world as a single black box. Manipulation of data doesn't happen directly but happens through the logic bound to that data. That is, via methods on the object. Popular examples here include C++, Java, and PHP.
- Prototypical OO programming
- Aspect-oriented programming
- A relative newcomer on the scene, AOP is based around creating join points between different parts of a system. That is, places where one logical routine can inject itself into another without having to modify either routine directly.
(Note: I'm sure some purists will say that I'm grossly over-simplifying one or more of the styles above. They're probably right, but for the sake of argument I'm only looking at some aspects of those approaches. If you want a more complete treatment, that's what the links are for. I do recommend reading them with an open mind.)
There's a very important fact about the above designs that is important to keep in mind: They're equivalent. It's been proven mathematically that procedural and functional languages are equally expressive; that is, any algorithm you can implement in one can be implemented in the other. OOP and AOP are essentially just outgrowths of procedural programming and frequently are implemented in multi-paradigm languages, so anything you can do in procedural language can be done in an OOP language or AOP language, or vice-versa. Declarative programming is the odd man out as many declarative languages are not turing complete, although many are if you try hard enough.
So if all of these programming paradigms are equivalent, why bother using one over another?
Simple: Each approach has different trade-offs that make it easier or harder to write certain types of algorithms. Not "possible"; Any functionality you can implement in functional languages can be implemented in an aspect-oriented language, and vice-versa. They make it "easier". Tthe amount of code and the amount of incomprehensible complexity involved will vary greatly. What makes different paradigms easier to apply to certain algorithms? What they don't let you do.
That's right. What makes a programming style easier is what it doesn't let you do. Because if you know for a fact that certain things are not possible, you can make assumptions based on that impossibility that make other tasks easier.
Functional: Logic centric
For example, in purely functional languages you know for a fact that the same set of inputs to a given function will always produce the same result. That means the compiler itself can optimize away multiple calls to the same function with the same parameters. Go ahead and call a function multiple times. Don't bother caching the result. The language semantics themselves will do that for you without any thought on your part. Haskell, I believe, does exactly that. It also makes Verifiability easy; you can mathematically prove the correctness of a particular function independent of the rest of the system. That can be very useful in a fault-intolerant system, such as, say, nuclear reactors or air traffic control where bugs become really really dangerous.
Since you know, for a fact, that a function will not affect any code outside of itself (aside from its return value), there's no requirement that a function have access to any data except its own parameters. It doesn't even have to be in the same memory space as the rest of the program... or even on the same computer. Witness Erlang, where every function can run in its own thread, or its own process, or even in a process on a different computer without syntactic changes. That makes programs written in Erlang extremely distributable and scalable, precisely because functions are incapable of producing side-effects.
Of course, for some use-cases the program structure functional languages require becomes horribly nasty. That's especially true for programs that are based around manipulating state over a long term. That's the trade-off: a clear, logically simple structure that makes complex algorithms easy to build right and scales well but makes stateful systems harder to build.
Procedural: Instruction centric
Procedural programs were the other major fork in programming language theory. Procedural programming can start very simple as just a list of instructions, broken up into chunks (subroutines, or functions). Most also include global state in some form or another in addition to locally scoped state.
What procedural languages don't let you do is heavily segment your program. There is one big pool of subroutines that can be called pretty much at any time. You cannot bind a given subroutine to just certain data or vice versa. That makes the code highly unpredictable, as your function could be called from quite literally anywhere at any time. You can't make assumptions about the environment you're running in, especially if your system makes use of global variables (or their close cousin, static variables). There's no way to hide data, there's no way to control when a given routine can or cannot be executed, there's no way to protect yourself against another developer hacking his way into a subroutine that wasn't designed to be hacked into.
Which of course is also its power. Because it's so low-level and has no safeguards, you can hack your way into (or out of) most situations with enough effort. Because you're prevented from hiding data, you get a great deal of flexibility. That can be very good in some cases. On the other hand, that means that in general procedural programming lets you make no assumptions about the context of your system or its state.
Because you have such limited control, it's extremely difficult to do any meaningful form of unit testing. You can do functional testing (that is, testing of functionality at a high level) or integration testing, but you have no clearly separable units to work from.
Object-oriented: Behavior centric
There are lots of variations on object-oriented languages, each with their own subtleties. For the moment, we're concerned only with Class-and-Interface languages such as PHP. The interface part is important: In a Classic OO language, individual primitive values are irrelevant. The interface to an object, as defined by its class and interfaces, is what matters. The class forms a completely new data type with its own semantics.
Just as a string primitive has its semantics (e.g., length) and possible operations (split, concatenate, etc.), so too does an object. The internal implementation of the string is irrelevant: it may be a hash table, it may be a straight character array. As someone making use of it you don't know, nor do you care (except in C, of course). Similarly with an object, it has behaviors as defined by its methods. The underlying implementation is irrelevant.
The data within the class is tightly coupled to the class; the class itself is (if done correctly) loosely coupled to anything else. Data within the class is irrelevant to anything but that class. Because it is hidden away ("encapsulated" in the academic lingo), you know, for a fact, that only selected bits of code (in the same class) are able to modify it. Unlike in procedural code, you can rely on the data not changing out from under you. You can even completely restructure the code. As long as the interface doesn't change, that is, the behavior, you're fine.
That's an important distinction. In OO, you are not coding to data. You're coding to behavior. Data is secondary to the behavior of an object.
Because you have isolated data behind behavioral walls, you can verify and unit test each class independently. That is, assuming you've properly isolated your object. A lot of code doesn't properly do so, which defeats the purpose. (See my previous rants on dependency injection.)
Aspect-oriented: Side-effect centric
And finally we come to new kid on the block Aspect-oriented programming. In some ways, AOP is the diametric opposite of functional programming. Where functional programming tries to eliminate side effects, AOP is based on them. Every join-point in AOP is a big red flag saying "please do side-effects here". Those side-effects could be all sorts of things. They could modify data, they could change program flow, they could initiate some other sideband logic and even trigger further side-effects.
What AOP offers is exactly that: The ability to modify a program without modifying a program. Once a join-point is established, you can alter the data or program logic at that point without changing any existing code. That provides a great deal of flexibility and extensibility, but at the expense of control.
Once you introduce a way to allow 3rd party code to modify your logic flow or data, you surrender any ability to control that logic flow or data. You can no longer make assumptions about your state, because you've built in a mechanism to allow your state to change out from under you. The way you compartmentalize your code is to make it impossible to fully compartmentalize your code. (Ponder that one for a moment...)
Functional approaches emphasize Verifiability, Testability, and Scalability at the expense of Modifiability, Extensibility, and in some cases Understandability.
Procedural approaches emphasize Modifiability, Understandability, and Expediency at the expense of Testability, Verifiability, and if you're not careful Maintainability.
Object-oriented approaches emphasize Testability, Modifiability, and Scalability at the expense of Extensibility, Expediency, and if you have a poor design Understandability.
Aspect-oriented approaches emphasize Modifiability, Extensibility, and Expediency at the expense of Testability, Verifiability, and arguably Understandability.
Oh great, so which one do we want to use? Which approach is best? The one that best fits your use case and priorities, of course.
Because all of these approaches are perfectly viable depending on your use case, most major programming languages today are multi-paradigm. That is, they support, at least to some extent, multiple approaches and ways of thinking about program logic.
PHP began life as an entirely procedural language. With PHP 4 it started adding object-oriented capabilities, although those didn't really come into their own as a viable alternative until PHP 5. PHP 5.3 introduced anonymous first-class functions, which while not pure functional programming since they still allow variables to be changed do allow programmers who are so inclined to write in a more functional way.
Although most aspect-oriented implementations are built atop object-oriented models, PHP supports procedural-based AOP. In Drupal, we call it hooks.
module_invoke_all() becomes a joint point, and a hook implementation becomes a pointcut.
(I am by no means the first to call Drupal's hook system a form of AOP. I just think it's a particularly good way of describing them.)
To be fair, without native syntactic support hooks are a rather clunky, hacked-up poor man's AOP, but conceptually it is still AOP. They have the same implicit trade-offs: Extremely flexible when used appropriately but totally destroy any hope of isolating a system to unit test it or do interface-driven development.
The fact that they're also bolted on top of a non-AOP language but not documented as being AOP, or applied consistently, is also a major stumbling block for new developers, especially those who have been brought up in a predominantly OO world.
Just as it's possible to emulate AOP in procedural code, it's possible in object-oriented code as well. There are many OOP patterns that give you all the same flexibility as AOP, for instance, in sometimes more verbose ways. Observer and Visitor patterns come to mind in particular. Again, it's not a question of can you implement a given design but how easily you can do so, and at what cost.
Nothing forbids the mixing and matching of different approaches, either. Take Drupal 7's Database layer. It is mostly straight up OO -- Modular, dependency-injected, self-contained, interface-driven -- but throws in some AOP in the form of hook_query_alter() and has procedural convenience wrappers such as db_query(). I certainly don't claim that it's a perfect balance, but it does show how multiple approaches can be leveraged together.
When considering how to tackle a given problem, or how to use a particular language feature, it's not enough to say "well I like X" or "approach Y is stupid". That is a naive approach, and tends to lead to spaghetti code. (Pasta exists in all languages.) Instead, we should ask what our priorities are, what we're willing to give up, and what we're willing to do in order to mitigate it. We always have to give up something. Always.
Which cost you want to pay is not always an easy balance to strike. Do you favor robustness (Testability, Verifiability, Scalability, data hiding, encapsulation, etc.), flexibility (Modifiability, Extensibility, bare data, etc.) or simplicity (Expediency, Maintainability, possibly Performance, etc.)?