While there exist many programming paradigms, there are three popular styles frequently used today. Namely, procedural programming, object oriented programming, and functional programming. While many languages adopt features across paradigms, most languages idiomatically prefer one style over the other. That is to say, even though these three methodologies are not mutually exclusive, the practical application within a language may have varying levels of support based on language features and community sentiment.
All of these styles have been known for many years and could easily be a post (or book) on their own. However, I will attempt to briefly introduce these styles in a way that I propose is similar to "past, present, and future." While I strongly believe all three paradigms will continue to exist long into the foreseeable future, I would be remissed to suggest that the industry isn't continuously evolving. As a result, while I believe any strong programmer should-- at the very least-- be adequately familiar with all of these styles, I do believe emphasis should be placed on learning more relevant technologies.
Procedural Programming
Common Languages: C, Fortran, BASIC, COBOL, Go
As you can see by the common language list, procedural programming has all but fallen out of favor for modern language design. With the exception of Go, all the languages on that list are at least 50 years old. While most newer languages are not adopting this programming style, many systems still run on these languages. As a result, they have certainly stood the test of time which would imply there are good ideas here.
So what is this procedural programming thing? In a single sentence: a collection of predefined sequential statements (i.e. procedures) which manipulate system state. In a less dense way, procedural programming allows programmers to write re-usable blocks of code to perform actions within their program. If you've done a lot of programming in the 21st century, this concept may seem painfully obvious to you since almost every modern language has first-class support for methods, libraries, and even package management nowadays.
A key distinction here from other paradigms (i.e. object oriented), however, is that your procedures and data are entirely distinct. The procedure takes some input, mutates it in some way, and optionally returns some status code to the caller.
Procedural Example
Let's examine a bit of C code to see procedural programming in action.
|
int mycpy(void* output, const void* input, size_t num_bytes) { size_t i = 0; if (num_bytes < 0) { return -1; } for (i = 0 ; i < num_bytes ; ++i) { ((char*) output)[i] = ((char*) input)[i]; } return 0; } |
For you C programmers out there, this probably looks pretty familiar: it's a naive, modified memcpy implementation. Namely, if the provided num_bytes value is less than 0 then the algorithm immediately returns with an error code otherwise it proceeds.
This example is demonstrative of typical procedural form. If your application often has to copy memory, you wouldn't want to have to write this procedure several times. You can see that we named our procedure "mycpy" for it to be reused throughout our application. Moreover, our return value is a status code rather than a usable value. Namely, it indicates whether or not our procedure succeeded or failed. Finally, the actual result of our computation is stored in the output argument provided by the caller.
While not all functions will be constructed this way in procedural languages (i.e. it is not uncommon for these languages to return the result directly if it is a primitive type), the general form for complex computations is as follows:
- Take the "result" object as input from the caller
- Perform predefined computation
- Store result in the "result" object (i.e. state mutation)
- Return appropriate status code (i.e. if error encountered provide error code, else success code)
In summary, procedural languages exhibit great ideas around code reuse and how to manage mutations and errors cleanly. Code written in procedural style often reads fairly well and is easy to follow for single-threaded applications. However, with all of the state mutation, it can become complicated for multi-threaded applications which are omnipresent in today's technology.
Object Oriented Programming
Common Languages: Java, C++, Python, Objective-C, C#
Another well-known programming paradigm is Object Oriented Programming (OOP). In my estimation-- based on both the job listings I've seen and the solicitations I've received-- this is the most popular style in-use today. If you don't explicitly see familiarity with OOP on the job requirements, you will almost certainly see at least one of these languages listed here on many job requisitions for software engineers today. These languages are typically the "heavy hitters." That is, they're know to be the languages people usually go to for performance, reliability, and scale: especially in well-established and/or enterprise companies.
Object oriented programming is-- as you may have guessed-- centered around the concept of objects. Much like procedural programming, we'll continue to mutate state. However, rather than having the caller join together the data and the procedure, we'll instead combine the two. This is the foundation of what an object is; it's both data and a set of common operations that can perform computation on that data.
At first glance, this sounds like a marginal improvement over procedural programming. The most obvious benefit is that since your methods are now bound to your data, you don't have to pass the data object in. That's great, but is it really worth all this fuss? Well, as it turns out, having objects enables an entirely new class of abstractions to work with. You now have inheritance, encapsulation, and polymorphism (IEP). Those are a couple of big words:
- Inheritance. This is when an object derives a set of properties (methods and data) from another object. It typically represents an "is a" relationship (i.e. a Car is a Vehicle).
- Encapsulation. You can now hide all the internal details about your model. In theory, if you have modeled your objects properly, you can avoid leaking any internal details and the caller can use interfaces without having to look at the code.
- Polymorphism. This allows you to treat a specific type as a more generic type; it's the other side of inheritance. Specifically, if you want to perform an operation on all Vehicle classes in your system, you can do so. Whether you provide it a Car or a Boat-- as long as they both inherit Vehicle-- is irrelevant. You can simply treat them as vehicles without any additional code.
It should now be clearer how OOP can actually be a stark improvement-- by way of code reuse and, ideally, more powerful abstractions-- over procedural programming. While we'll be focusing solely on what we have mentioned for now, OOP enables other design patterns as well (mixins, object composition, etc.) that we will discuss in a separate post. Even though we don't go through them here, curious readers should investigate further to see how these patterns behave and what problems they solve.
Object Oriented Example
Below is an example in Java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57
|
public class Main { public static abstract class Vehicle { private String direction = "North"; public abstract String getName(); public String getDirection() { return direction; } public void turnLeft() { switch (direction) { case "North": direction = "West"; break; case "South": direction = "East"; break; case "East": direction = "North"; break; case "West": direction = "South"; break; } } } public static class Car extends Vehicle { @Override public String getName() { return "Car"; } } public static class Boat extends Vehicle { @Override public String getName() { return "Boat"; } } public static void main(String[] args) { List<Vehicle> vehicles = Arrays.asList(new Car(), new Boat()); boolean first = true; for (Vehicle vehicle : vehicles) { if (first) { vehicle.turnLeft(); first = false; } vehicle.turnLeft(); System.out.println("Type: " + vehicle.getName() + ", direction: " + vehicle.getDirection()); } } } |
This example continues from where I left off above. That is, there is a base class which we refer to as Vehicle which does not have any implementation itself. However, we have two types of vehicle which inherit the Vehicle class: Car and Boat. Each of these inheriting classes actually implement the getName() function. Finally, if you observe our use of it in our main function, you'll realize that both our Car and Boat are stored in a list of Vehicle objects (i.e. polymorphism). Likewise, we also benefit from code reuse in this abstraction. Observe that we only had to implement a single turnLeft() and getDirection() function, and both vehicles were able to gain this functionality without additional code. The direction data variable is silently stored within the object (i.e. encapsulation). As you can see, putting the data and methods in the same container has provided us with some additional code reuse power.
Overall, object oriented programming is incredibly important in the industry today. While it lends itself well to natural abstractions such as the Vehicle example, it often requires a lot of forethought and/or refactoring to avoid leaky abstractions in complex systems. As a result, design can often become more difficult in object-oriented systems than procedural systems due to its flexibility. However, similar problems still exist in multi-threaded applications as they do in procedural systems: when the codebase becomes large, it is often difficult to follow the mutations through the system. This problem is somewhat mitigated as entire objects can be written to be made thread-safe, but any paradigm which advocates data mutation opens itself up to the same class of problems. While nothing is perfect, OOP seems to strike a balance for most programmers. Namely, it's an understandable concept with great power and flexibility to eliminate code redundancy.
Functional Programming
Common Languages: Haskell, Lisp, OCaml, ML, Scheme
In my opinion, this section begins the future of programming. While functional programming has been around for quite some time, its design eliminates entire classes of problems encountered in other languages. Similarly, many traditional arguments against the practicality of functional programming (i.e. performance) are now negligible for all except the most specialized use-cases. Then again, I likely wouldn't put the JVM on an embedded system either, so this problem is not purely related to the functional paradigm.
Functional programming (FP) takes a step away from what we've been discussing. Rather than thinking about how the computer executes instructions and moves data, we instead look at our problem in a more logical way. There are no longer methods or procedures but instead functions. That is, a set of operations that take input and produce output. Likewise, data and functions are distinct elements; we no longer couple the two like in OOP. One of the most important notions in FP is that of immutable data. Logically, once something is created it cannot be modified. If you want to change an object's values, create a new instance with the updated data and return that to the caller. Before you stop reading here, remember two points I've been making:
- Even if there were full copies of your data each time you needed to make a change, processors are fast enough today for most applications
- I've mentioned that this is a logical model. More specifically, compilers can optionally optimize in clever ways to mutate data if it is appropriate
- The benefit here is that the programmer doesn't have to worry about this and, therefore, mistakes are minimized
Functional programming lends itself well to a lot of other cool concepts (i.e. lazily initialized collections, lazy function evaluation, correctness proofs, etc.), but we don't have the space to go into all of that right here. However, you'll notice almost all modern languages are adopting the functional programming paradigm. While I tout FP as being the "future," the fact is that it's already here. Python has always supported functional map, reduce, lambdas, and list comprehensions. Similarly, C++ and Java have adopted a whole set of functional concepts in their recent releases as first-class citizens in the language. Moreover, with callbacks and the like, Javascript makes use of a lot of functional concepts and it is often idiomatic to write functional Javascript. What I'm getting at is that the industry is clearly learning. There has been a lot of griping that "functional is hard," but as people begin to understand it they are realizing that it is actually a cleaner, more concise way to model the world. Since most people are not writing super specialized embedded systems, functional programming languages often exceed their minimum technological requirements.
Functional Example
As you can tell, I'm a bit biased. I really like functional programming (even though I write mostly OOP at work). In any case, I will provide an example in Haskell to demonstrate some of FP's power. In FP we're going to model things as higher level abstractions. With this in mind, this is neither the cleanest nor most concise way to express this in Haskell, but it should be explicit about what's going on:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44
|
-- Type class describing the properties of a vehicle class Vehicle t where name :: t -> String direction :: t -> String turnLeft :: t -> t -- Two different data instances data Car = Car { carName :: String, carDirection :: String } data Boat = Boat { boatName :: String, boatDirection :: String } -- Implementations for each data instance to behave like a vehicle instance Vehicle Car where name = carName direction = carDirection turnLeft inVehicle = Car (name inVehicle) (leftDirection inVehicle) instance Vehicle Boat where name = boatName direction = boatDirection turnLeft inVehicle = Boat (name inVehicle) (leftDirection inVehicle) -- Helper to determine the left direction leftDirection :: (Vehicle a) => a -> String leftDirection v = let dir = direction v in case dir of "North" -> "West" "West" -> "South" "South" -> "East" "East" -> "North" _ -> "Very lost" printVehicleInfo :: (Vehicle a) => a -> IO () printVehicleInfo v = putStrLn $ "Name: " ++ (name v) ++ ", Direction: " ++ (direction v) main :: IO () main = do let boat = Boat "Boaty McBoatFace" "North" car = Car "Porsche" "North" printVehicleInfo $ turnLeft car printVehicleInfo $ turnLeft $ turnLeft boat printVehicleInfo boat printVehicleInfo car |
In this example, you can notice several things which we have already discussed. First of all, we create a Vehicle class which describes the behavior of all vehicle types in our program. The next bit of code then defines our data objects (i.e. records). You will notice that there is no code associated with these data objects.
Next, we get to a bit of implementation code. Namely, we define that both data types are indeed Vehicle types conforming to the class definition we outlined above. From this point on, we can generically treat each data type as a Vehicle. While this may not seem incredibly useful in this particular example, it enables us to access any code which knows how to operate on the Vehicle class. In this example, those are leftDirection and printVehicleInfo. However, this feature becomes particularly useful for very common operations such as Traversable operations.
Finally, if you direct your attention to the main method, notice the order of our calls. We run a few turnLeft calls on our boat and car before printing their information. Then, after that, we print the original information. It is important to recognize that the original boat and car instances remain unchanged even though the results of turnLeft calls were correct.
While not completely evident here, the power in functional programming is in its ability to generalize concepts. Similarly, since data is separate from functions which operate on it, it also increases code reuse. If you have a data type provided by someone else and you want to perform a certain set of well-defined operations on it, you can overload the appropriate typeclass to do this without having to reimplement any additional functionality. Moreover, the immutability of data structures dramatically improves the logical model of your system. Namely, in multi-threaded applications, nothing can "accidentally" change anything else. This allows us to mostly do away with locks and other pitfalls associated with multi-threaded programming.
Conclusion
There are many programming paradigms and a lot to know about each before determining which is best for your use case. While I cannot assert that there is single best solution for all problems, I do claim that there is often a better choice for a specific problem. I have introduced you to three major styles in programming today that all have practical relevance. It is well worth your time to take a deeper look into any of these paradigms and determine which may be best fit for your next project.