Rather than trying to pin down what programming paradigms like ‘object-oriented’ or ‘functional’ are, I will write about what I think is important in programming and then discuss how paradigms help or hinder writing programs properly.
Loosely coupling the environment
To work for us, programs must interact with their environment. The program could be running inside a virtual machine inside a laptop. The laptop may be part of a network that contains other devices and the network may be connected to the internet. All of that is the environment the program can interact with. If it doesn’t, it better not run.
Interactions with the environment make programming more difficult. Code reuse requires a similar environment to work properly. In particular, testing interactive programs requires a realistic test environment. The code of an interactive program doesn’t show what is happening in the environment. Therefore maintainers must use their imagination or rely on documentation to figure out what a program does. Similarly, static analysis tools like type checkers miss a lot of information that might help in finding bugs.
The best way to deal with the environment in a program is, therefore, to split off the parts that interact with the environment into separate modules. The result is a pure core that is loosely coupled to the environment. The smaller the interactive modules are, the larger the part of the program that is easy to work with.
The cost of loose couplings
Loosely coupling the environment has costs that are unavoidable no matter what language or paradigm you use. The most obvious is that the loose couplings must be traversed for the program to have its interactions. A more technical problem is that performing computations requires interactions with the memory and processors of the machine the program is running on, whether those computations are pure or not. Let’s go into more detail here.
Suppose the code says that the program reads a file, processes its data and writes the results to another file. To become a program, the code has to pass a translator, like a compiler that translates it into a machine language, or an interpreter that translates it into direct action. The translator has to generate both the interaction needed for reading and writing files, but also the interactions needed to store the data and the results in memory and to perform calculations in the processors. A smart compiler could rewrite the source code to undo the loose couplings and to add the extra interactions to produce efficient machine code. A smart compiler is a slow compiler, however. A faster but less smart translator will leave more work to be done while the program is running. Either way, we pay for the convenience of loosely coupling the environment.
Programming paradigms are not rigorously defined. Programming languages are almost never simply imperative or declarative but combine these aspects in the paradigms they support. For example, imperative Java code consists of class, interface, and method declarations which are preserved by the Java compiler. Java is generally considered an object-oriented language, so apparently, the object-oriented paradigm admits both imperative and declarative elements. Moreover, no matter what language or paradigm you use, loosely coupling the environment is possible to some extent.
In practice, declarative allows you to go much further. Programs can still be declared to interact with the environment directly, but in most cases, it is just as easy to declare a ‘hole’ where interactions can be injected as needed. The functional programming language Haskell goes a step further by forcing all interactions with the environment to go through the IO-monad, which I imagine encourages writing loosely coupled programs.
Imperative languages, on the other hand, are often ‘close to the metal’. That means ‘tightly coupled to the nearest environment’. They require time and memory management instructions everywhere. In essence, every program is coded as an interaction between the programmer and a virtual machine. This makes the job of a translator much easier–you can have faster compilers that produce faster programs. It also means loose coupling is only possible for the more remote environment. Unfortunately, it requires more code because of all the machine instructions. The result is a slower program unless the translator understands what you intended. This way, imperative languages encourage tightly coupling the remote parts of the environment as well.
The imperative paradigms were needed when computers had small memories and slow processors. Both the performance of the translator and the resulting program limited how smart an interpreter could be. These days are past us. Moreover, modern multicore machines require instructions different from those hard-coded in imperative languages that were designed for old-fashioned single-core machines. Those are good reasons to shift away from imperative and towards declarative paradigms.