My understanding of OOP and FP
In my previous article, I discussed my understanding of programming languages in general. In this article, I will concentrate on explaining object-oriented programming (OOP) and functional programming (FP) as I understand them.
To summarize briefly: OOP is used to express ideas about universal objects, whereas functional programming primarily focuses on specific types of objects.
Encapsulation and Polymorphism
Before we delve into OOP and FP, let's first talk about my understanding of encapsulation and polymorphism. These terms frequently appear in discussions about OOP and occasionally in FP. They might seem unusual at first, but they are tied to two elementary notions: the what and the how.
Encapsulation is designed to separate how from what. This separation allows users to focus solely on the what, while the how becomes irrelevant to them. Let's consider a function as an example. The function's signature informs us about its purpose, the what, whereas the body of the function describes the method of implementation, the how. As a user, or a caller of the function, you only need to concern yourself with the function's signature. The body of the function can be modified without affecting the caller, provided that the function's signature remains the same.
Visibility control is another facet of encapsulation. By marking a method (or function, field, etc.) as non-public, we express that it belongs to the how category. This means it can be modified or removed without affecting users who cannot access it.
Polymorphism, on the other hand, permits us to perform a single task in various ways. Polymorphism takes on different forms in different programming languages, such as virtual functions, interfaces, and higher-order functions. Despite these differences, the core principle remains the same: we define what a method (or function, etc.) is, in other words, its signature. The implementation or the how can differ. This allows calls to the same method to have different implementations.
In my view, encapsulation and polymorphism are tools that express the interplay between what and how. Various programming paradigms like OOP, FP, or languages like Rust, exhibit subtle differences in my use. My focus is more on whether the expression is intuitive enough, not merely the paradigm it belongs to.
OOP
OOP is an effective approach to represent various objects in programming, which explains its widespread use. Any criticism of OOP for its use of design patterns may not be entirely justified, as discussed in this article.
In OOP, classes serve to describe objects. Methods are used to express the actions that these objects can undertake, while properties convey the attributes of these objects. The feature of visibility control allows for encapsulation. Non-public members denote the 'how' - the specifics of how the object functions. On the other hand, public members indicate the 'what' - the operations that can be performed and the data that can be accessed.
The concept of inheritance in OOP helps express 'is-a' relationships. If class A
inherits from class B
, it suggests that A
is a kind of B
. Instances of class A
can be viewed as objects of type B
, possessing the methods and properties of class B
. To achieve polymorphism, classes may contain virtual methods that subclasses can override. Interfaces, on the other hand, are used to highlight common traits between classes. Implementing an interface is akin to inheritance in that it also signifies an 'is-a' relationship, but it further specifies that a class (and other classes implementing the same interface) share a common trait. A single class can implement multiple interfaces, reflecting its potential to share different traits with different classes.
There are programming languages not typically classified as object-oriented, given their lack of classes and interfaces. However, as I often stress, what matters is the ability to express ideas. If a language can communicate relationships with the same level of clarity, it should be deemed sufficient. Take Rust, for instance. While not strictly an OOP language, it leans towards composition over inheritance and uses traits to express shared characteristics. Despite not being formally OOP, Rust includes features for encapsulation and polymorphism. Hence, I believe Rust is not any less capable than OOP languages for this reason.
FP
While object-oriented programming (OOP) is indeed robust, one might ask, "Why do we need FP?" The answer lies in the uniqueness of some classes. Certain cases require objects with unique structures. Using only the general OOP features may lead to code duplication. In my view, the purpose of FP language features is to better express specific groups of objects in a manner that aligns with our ideas.
I advise against completely abandoning OOP in favor of FP, or vice versa. OOP is a versatile method for object expression, suitable for universal use. Yet, the uniqueness of some objects is an inherent complexity. Ignoring this won't simplify matters as we'll still need to handle these specific cases. Instead, recognizing and articulating these unique objects is a more beneficial approach.
In subsequent sections, I will explore FP language features, elucidating the types of objects each feature aims to express. My focus lies on the expressiveness of these languages. In my perspective, if a language can embody a certain idea, it possesses that feature. However, the means through which a language expresses an idea can vary. I use Rust, Kotlin, Python with typing, and TypeScript as examples, but other languages like F# and the newer versions of C#, might also have these features.
Optional and Null Values
Many programming languages include a value expressing "non-existence" — null
, nil
, or undefined
. However, these languages often lack a clear means to express that a value is optional. Even when a value is expected to exist, the language doesn't prevent you from using null
, and errors only occur when operations are attempted on this null
value.
Conversely, functional programming languages typically lack values like null
. These languages employ a type Option<T>
to represent an optional value. An Option<T>
can contain a value of type T
or signify "non-existence." You cannot directly operate on an Option<T>
as if it were a type T
; the value inside must be accessed after verifying its existence. Languages like Scala and Rust follow this method.
Alternatively, we can retain null
, but whether a value is optional will be indicated in the type. In Kotlin, you append a ?
to the type, while in TypeScript and Python's type annotations, this is indicated through a union of the type and a non-existent value. If you attempt an operation that requires the value existence on a optional type without checking its existence, the type checker will flag an error.
Both methods provide accurate indications of whether a value is optional. They not only make the code align closely with our idea by expressing whether a value is optional, but also enable type checkers to identify errors that fail to appropriately handle missing optional values.
Sometimes, we might want to "fail fast" when a value doesn't exist. If the absence of an expected value signifies that the environment or configuration doesn't meet requirements, or that there is a logical error (a bug), you might want the program to terminate immediately. In Kotlin, you can use !!
, and in Rust, the unwrap
or expect
method. This process effectively handles the non-existence scenario, expressing the requirement for existence. However, these should only be used in situations where absence necessitates a "fail-fast" response. If non-existence needs proper handling, then it should be done appropriately.
Higher-order Functions and Actions
There are times when you might want to express ideas like "make a new list from an existing list, but multiply each element by 2" or "set up an action to display a message when a user clicks a button." An action can be the parameter of another action, like "multiply by 2" or "display the message." In programming, we often represent these actions as functions. Furthermore, in FP (functional programming), a function can also be an object, allowing it to be passed as an argument.
Modern programming languages mostly support higher-order functions. Some languages utilize interfaces to represent functions, while others employ specialized types. Many languages offer a feature known as "lambda expressions" to create function objects, which essentially form the body of the function.
Let's consider an example. Suppose we have a list of data with score
and id
, and we want to filter out the id
of data entries with a score above 60. The following Kotlin code does the job:
1 | l.filter { it.score > 60 } |
This code succinctly expresses the idea of selecting data with a score above 60 and extracting their id
. If we were to use a more traditional approach, we would need to create a mutable list to hold the results, iterate through the original list, and after checking each score, insert the id
into our result list. This process complicates our initial idea, making it less straightforward.
Immutability
At times, it's important to express the concept of an object's immutability. A typical example is when an object holds information that multiple references will read. You'd want everyone to access the same unaltered data, similar to how an announcement on a bulletin board should be read but not changed. In concurrent programming, immutable objects help prevent data races.
Strings are a commonly used example of an immutable object. In most programming languages, a string is represented by a reference or a pointer to the string content. Because strings are designed to be immutable, we don't have to worry about their content being changed by other references. This allows us to focus more on the string content rather than the object it refers to.
In Java, each primitive type has an equivalent boxed type. Like other class objects, these boxed types are represented by references. However, their immutability means we usually don't need to worry about which object is referenced, only its value. Languages like Kotlin and Scala leverage this by minimizing the difference between primitive types and boxed types. They provide a single type for each numeric type, leaving it to the compiler to decide whether to use the primitive or the boxed type. The premise of this design is the immutability of boxed types.
In an ideal scenario, immutable objects should never be modified. However, achieving this can be challenging. Sometimes, third-party functions might operate on mutable objects without actually modifying them. In these cases, a practical solution is to use conventional immutability. Here, while mutable operations on objects intended to be immutable may be technically possible, they should not be performed. Read-only can be used to reduce the risk of unwanted modifications. Providing a read-only object reference prohibits the modification of the object. Although other references could modify the object, this approach expresses the intention of immutability. Read-only can be implemented via an interface containing only read operations.
In Kotlin, interfaces for collections that don't begin with Mutable
(like List<T>
, Set<T>
) are read-only. Also, functions used to construct collections that don't start with mutable
(like listOf
, setOf
) yield immutable collections.
Rust employs borrow checking to ensure that a value with multiple references stays immutable unless explicitly enclosed in a unique container like RefCell
or Mutex
.
Python includes immutable collections like tuple
and frozenset
. It also provides a read-only dictionary wrapper MappingProxyType
and read-only types such as Sequence
, AbstractSet
, and Mapping
within the typing
module.
In TypeScript, the readonly
keyword can enforce read-only properties or arrays, and Object.freeze
can prevent an object from being mutated.
Furthermore, there are third-party libraries that provide immutable data structures for these languages. Some of these structures offer efficient partial update operations. For instance, a set can be created with some elements added (without mutating the original set) without having to clone the entire set. Instead, it shares part of its structure with the new set.
Record
A record in functional programming signifies an immutable object made up of properties. Their equality is determined by the values of the properties, not the references. Records can be built by defining values for properties and support partial updates. That is, a new record can be created based on an existing one by defining the values for some properties while keeping the rest unchanged.
Kotlin and Scala use data class
and case class
respectively to depict records. In Kotlin, you can use val
to keep properties immutable. These languages support named parameters, so it is natural to build a record by defining property values. They also contain a method to clone the object and let you define some property values, thereby enabling partial updates.
In Rust, records can be represented through a struct with #[derive(PartialEq, Eq)]
, and the borrow checker controls its mutability. Partial updates can be achieved using the struct update syntax.
In Python, the dataclass
annotation from the dataclasses
module, used with the frozen
option, allows you to represent records. It generates the __eq__
method. To partially update a record, the dataclasses
module provides a replace
function.
In TypeScript, a record can be presented as an object type. Fields can be made read-only with the readonly
keyword, and Object.freeze
ensures that the object won't be altered. Partial updates can be performed using Object Spread. However, TypeScript currently doesn't support customizing object equality.
Newtype
Some data is represented by an existing type but bears unique meanings and constraints. For instance, a username can be represented as a string, but it's more than a string due to specific restrictions and its unique significance, differentiating it from other string-represented data like an email address.
FP offers a Newtype feature to represent such data. Considering the username example, we can use a string wrapper class (a class containing only a string) to symbolize the username, which equates based on the string value. This approach offers several benefits:
- It clearly identifies it as a username (as opposed to a regular string).
- It indicates the boundary between validated and unvalidated[1] values.
- If an unvalidated string is mistaken for a username, a type check error occurs.
- If an email address is incorrectly used as a username, a type check error occurs.
In Kotlin, value class
es can represent newtypes. To enforce the value being valid, you can perform validation in the init
block or make the constructor private and provide a validation method. The latter allows invalid errors to return more than just exceptions, like the failure of Result<T>
.
Newtypes can also be depicted by a data class, which is similar to a value class, but if you don't validate in the init
block, even with a private constructor, objects with invalid values could be created by the copy
function.
In Rust, Newtypes can be implemented with a struct that contains a single field. If you want to enforce the value being valid, the field can be made private. In this case, you should provide a verification method that returns a newtype value, along with methods to access and immutably borrow the field.
Python can implement newtypes using NewType
from the typing
module. It doesn't enforce the value being valid, but explicitly constructing newtype values considerably reduces the error of mistaking an unvalidated value for a validated one.
In TypeScript, new types can be realized using the branded type pattern. Like Python's NewType, it cannot enforce the value being valid.
Algebraic Data Types
Certain data types comprise multiple variants, each with distinct contents. In our dealings with data, we often need to approach each variant differently. Algebraic Data Types (ADTs) in functional programming allow us to articulate this kind of data.
An "is-a" relationship can often be observed between the data and its variants. You might prefer to express the data as a class and the process of handling it as a method. Then, each variant would inherit from the data class, overriding the methods to facilitate variant-specific processing. However, with data, the variants and their content are usually known and relatively fixed, while the data processing methods are often unpredictable and extendable.
The SOLID principles include the "open-closed principle", which states that a class should be open for extensions but closed for modifications. Importantly, this rule doesn't explicitly define what constitutes a modification or an extension. Sometimes, adding a subclass could be viewed as an extension, while adding a method could be viewed as a modification. Other times, adding a process could be viewed as an extension, while adding a variant could be seen as a modification. None of these possibilities should be overlooked, and Algebraic Data Types align well with this latter perspective.
In Rust, the enum
keyword allows us to naturally express this data. Union type representations can express this in TypeScript and Python's type annotations. Kotlin and Scala allow marking classes with sealed
to inhibit external class inheritance.
To process this data, Rust and Scala offer the pattern matching feature, a staple in FP. With pattern matching, you can write code for each variant of data, with the content of each variant bound to its specific code path's local value. Pattern matching ensures comprehensive coverage of all cases through exhaustiveness checks.
In fact, the Option
type in Rust and Scala, referenced in the Optional and null section, is an Algebraic Data Type. It has two variants: existing (usually called Some) and non-existing (usually called None). Only the existing variant carries a value.
In Kotlin, TypeScript, and Python type checking, you can use smart-cast to express this processing logic. Provided that the data belongs to a particular variant, the data will automatically assume the type of that variant, granting access to its content. These languages also enforce exhaustiveness checks.
If exhaustiveness checking isn't inherently supported, patterns like the Visitor pattern can implement it. However, they introduce complexity in expressing.
Utilizing ADT, we can establish a type that signifies success or failure. This can be an alternative to the exception mechanism for error handling in some languages. You can find my thoughts on error handling in this blog.
Conclusion
In this article, I've shared my perspective on Object-Oriented Programming (OOP) and Functional Programming (FP). I view OOP as a broad method for expressing objects, while FP is better suited for specific cases.
You might note that I've refrained from discussing category theory or the "monoid in the category of endofunctors™". I'll be crafting a blog post about my standpoint on category theory. Here's a spoiler: I am not in favor of applying category theory in programming.
The term Unvalidated refers to a value that hasn't been validated yet. It could either be valid or invalid. ↩︎