Imperative programming is the familiar approach to writing step-by-step instructions for a program to solve some problem. It's the most typical approach for writing programs today. If you're a programmer, you're undoubtedly writing imperative programs at least at some level.
The declarative approach is altogether different and sometimes misunderstood. Or I should say at least that many developers are representing something which, while being more declarative, is not wholly declarative.
They are generally selling functional programming.
Everyone should invest in learning and using functional concepts if only for the profound way it will affect how you think about writing programs. However, writing ClojureScript, Elm, or JavaScript with a functional splash (underscorejs or ramdajs) is not declarative programming. It merely has a declarative leaning.
Programs are not decidedly imperative or declarative, but appear along a spectrum ranging from 0 to 1. The assigned value classifies how well an approach separates policy from intent — more on this in a moment.
SELECT firstname, lastname, email FROM Contacts WHERE state = "PA"
The go-to illustration is usually a T-SQL query. This is a valid starting point. Let's rank it a solid 1 for fully declarative.
Some, however, will also include the following:
var residents = _.filter(contacts, _.matches({state: "PA"}));
This is imperative code because it directly supplies the mechanism for achieving our result (.e.g. filter and matches). There is real value in extracting the imperative bits into functions that compose.
It's definitely a step in the right direction.
Our ultimate goal is to separate how to do it (policy) from what we want (intent). We've not done that. All we've done is abstracted the notion of filtering and matching by stuffing the for loop and if statement inside functions. The for loop and if statement remain, they're just out of sight.
It's obvious that what we want is contacts who reside in Pennsylvania.
Here's the cold, hard truth.
The only declarative program is no program at all.
We need to banish all signs of computation. We need to be left only with an expression of intent. That begs the question: If we do away with the computational part of the program (our functions), how is the program able to accomplish its goal?
Here's the answer: We don't know and we don't care. In fact, it's a great thing that we don't have to know or care.
Having eliminated policy, we are left with intent. That's ultimately all we care about. We want bark commands and have them obeyed. We don't want to concern ourselves with the particulars.
Find them and destroy them! - Agent Smith
How do we express intent without policy?
Messages.
Messages can take many shapes, but they are ultimately just data.
var query = {state: "PA"};
This snippet shows one way of representing our intent. But the truth is, we can design whatever representation we want.
var query = ["state" "PA"];
var query = new Query({filter: "state = 'PA'", order: "lastname"});
A declarative api necessitates data that can be serialized to text. The nature of text is that it can be sent across the wire and interpreted (parsed or deserialized).
That's what's great about T-SQL: it's just text. When the server receives it, it parses the expression into structured data of some sort in order to determine the intent. The same could be done with any potential data representation we devise.
For now, let's just say we transmit the query (what we want) over the wire using JSON.
{state: "PA"}
The server digests it and applies the computation using the same filter and matches functions shown earlier! All we've done is moved the actual computation over the wire — i.e. across a boundary.
var residents = _.filter(contacts, _.matches({state: "PA"}));
This separates policy from intent. Winning!
Just because the server today uses filter and matches doesn't mean it will tomorrow. That's what we want. We want to not care about how the filtering takes place. We just want to make an abstract request. We want the receiving agent, whether in close proximity (a nearby function...) or far away (...on a remote server) to deal with the details.
The wire offers a good constraint. It poses a boundary that forces us to think about how to cross it. This leads us to serialization.
Declarative programming doesn't necessitate a network boundary, but it does need some boundary. Without it we aren't forced to separate policy (how) from intent (what). The boundary forces an intent, however complex, to be fully expressed as data. At the same time, it forces the computation to be hidden away in a separate context.
All requests specify: This is what I want; I don't care how you get it.
The epiphany is this:
Functions cannot be readily sent over the wire.
Functions can close over the environment and hold reference to variables that existed at the point the closure was made. It's this ephemeral program state held by functions that cannot be easily captured and converted to raw text.
That is why data, once married to functions, is no longer declarative. (To illustrate: create a JavaScript object whose keys map to raw or partially applied functions and try to transmit it.) This characteristic of functions is why the boundary between policy (computation) and intent (data) must exist.
Thank you for reading, Padawan. You just leveled up.
Declarative programming in the simplest sense is data-oriented. It represents intent as data. It allows policy (computation) to come later. Thus, only that which can readily be sent over the wire is declarative.
Want to level up again?
Consider the Interpreter Design Pattern or — only after quaffing an Elixir of Monadic Understanding — the Free Monad.
Ponder their declarative properties.
You will attain further enlightenment.