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 intentions about universal objects, whereas functional programming primarily focuses on specific types of objects.

Encapsulation and Polymorphism

Encapsulation and polymorphism are 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 the users of code 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 example of encapsulation. By marking a method (or function, property, etc.) as non-public, we express that it belongs to the how category. It can be modified or removed when how it is implemented changes, without affecting users who cannot access it.

Polymorphism, on the other hand, allows us to perform a 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.

Object-Oriented Programming (OOP)

OOP is an effective approach to represent 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 describe objects. Methods represent the operations of these objects, while properties represent the attributes. Non-public members denote the 'how' - the specifics of how the object works. 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 expresses that A is a kind of B. Instances of class A can be viewed as objects of type B, having the methods and properties of class B. To achieve polymorphism, classes can include virtual methods that subclasses can override. This allows an object's virtual method to behave differently depending on which subclass instance it is. Interfaces, on the other hand, are used to represent common traits between classes. Implementing an interface is similar to inheritance in that it also represents an 'is-a' relationship. Additionally, it specifies that a class shares a common trait with other classes that implement the same interface. 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 intentions. If a language can express relationships with the same level of clarity, it should be considered sufficient. Take Rust, for instance. While not strictly an OOP language, it considers composition over inheritance and uses traits to express shared traits. 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.

Functional Programming (FP)

While OOP is indeed robust, one might ask, "Why do we need FP?" The answer is the uniqueness of some objects. Using only the general OOP features may lead to indirectness and 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 intentions.

I oppose entirely abandoning OOP in favor of FP, or the other way around. OOP is an excellent method for expressing objects and is suitable for universal use. However, the uniqueness of certain objects is an inherent complexity. Ignoring this in the programming language doesn't truly simplify things, as we will still need to find a way to represent these specific objects during programming. Instead, acknowledging and designing features for these unique objects is a more advantageous approach.

In subsequent sections, I will explore the features commonly found in FP languages, explaining the types of objects each feature aims to express. From my perspective, a language possesses a particular feature if the underlying intention of that feature can be directly expressed in the language. However, the specific way in which a language expresses an intention may vary. I use Rust, Kotlin, C#, Python with typing, and TypeScript as examples to explain how the intentions of these features can be expressed in languages that may not typically be considered functional.

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 use a type Option<T> to represent an optional value. An Option<T> can contain a value of type T or "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. Rust also follows this method.

Alternatively, we can retain null, but whether a value is optional will be indicated in the type. In Kotlin and C#[1], 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 intention 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 means 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 intentions 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, 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
2
l.filter { it.score > 60 }
.map { it.id }

This code succinctly expresses the intention 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 rewrites our initial intention, 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 to minimize 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) return 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.

In C#, interfaces for collections that begin with IReadOnly (like IReadOnlyList<T>, IReadOnlyDictionary<T>) are read-only.

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 expresses 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 uses data class to represent records. val can be used to keep properties immutable. Kotlin supports named parameters, so it is natural to build a record by defining values for properties. Data classes also contains copy method to clone the object and let you define new values for some properties, 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.

Records were first introduced as reference types in C# 9 and later as value types in C# 10. You can switch a record from a reference type to a value type, or vice versa, with minimal impact on its behavior. This is thanks to records being immutable, and their equality being based on the values of the properties, rather than on the reference.

Newtype

Some data is represented by an existing type but has 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 meaning, 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[2] values.
  • If a string is mistakenly used as a username, a type-check error occurs. This can be useful for catching mistakes, especially when input is used directly without undergoing validation.
  • If an email address is incorrectly used as a username, a type check error occurs.

In Kotlin, value classes 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.

In C#, new types can be implemented by a record type with one field. 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 virtual method. Then, each variant would inherit from the class, overriding the methods to implement 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 can express this in TypeScript and Python's type annotations. Kotlin allows marking classes with sealed to inhibit external class inheritance.

To process this data, Rust offers 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 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 is not inherently supported, patterns such as the Visitor pattern can implement it. However, these patterns introduce complexity in expression. In C#, certain Roslyn libraries[3] can be used to add an exhaustiveness check to pattern matching.

Pipeline

Sometimes, we want to express a series of steps where each step produces a result that is used in the next step. For example, consider the process of adding 5 to a number a, multiplying it by 3, applying a specific function ln, and then outputting the result. We could write this as print(ln((a + 5) * 3)). However, there are two aspects that are not straightforward. First, every step requires us to wrap the previous steps in parentheses. For instance, after writing a + 5, we need to add parentheses before writing * 3, and then again before ln, and so on. Second, the order in the code is the reverse of the actual process. The print at the beginning is actually the last step. This creates a discrepancy between our code and our thought process, and unless we think through the entire process beforehand, it can lead to frequent jumps during coding.

In functional programming, Pipeline is an operator for function calls (here exemplified as |>), which allows you to write a function call f(a) as a |> f. Combined with Lambda expressions, a series of steps can be represented by a continuous Pipeline. For example, the process mentioned above could be written as a |> x => x + 5 |> x => x * 3 |> ln |> println. This means that a is used as a parameter in a function that returns the result of adding 5, then this result is used as a parameter in a function that multiplies by 3, followed by applying the ln function, and finally printing the result. This way of writing more closely matches the series of steps in our thought process.

In some programming languages that support extension methods, such as Kotlin, C#, and Rust[4], pipelines can be expressed as an extension method. These methods take a higher-order function as a parameter and return the resultant value. For example, in Kotlin's standard library, there's an extension method let, and our example could be written as a.let { it + 5 }.let { it * 3 }.let { ln(it) }.let { println(it) }. In TypeScript, Lodash and Ramda contain functions that can be used to express a pipeline[5], although they also require prior wrapping once, and there is a pipeline proposal for EcmaScript[6].

The intention of a pipeline can also be expressed in a very simple way: using intermediate variables. For example, the process mentioned earlier can be implemented with the following code:

1
2
3
4
val plus5 = v + 5
val mul3 = plus5 * 3
val lned = ln(mul3)
println(lned)

These intermediate variable names seem redundant because they are used immediately after definition, and their meanings can be inferred from the context. These intermediate results don't have specific names in our thought process, but we need to manually name them when not using Pipelines.

One solution is to use simple names, like single letters. However, this approach has two issues. First, these names can easily conflict. This problem can be somewhat mitigated in languages like Rust, which allows the use of the same identifier, or in languages like Python with Typing, which allows an identifier to have different types and can be correctly inferred. The second issue is potential criticism for using meaningless identifiers. In most cases, identifiers should not be a single letter, but this is one of the few exceptions. An identifier doesn't have to have a meaningful name if it does not have a name in our thought process at all.

Conclusion

In this article, I've shared my perspective on OOP and FP. I view OOP as a broad method for expressing objects, while FP is better suited for specific cases.


  1. The feature of nullable reference types was introduced in C# 8. To maintain backward compatibility, a nullable context has been implemented. For more information, refer to the official documentation regarding Nullable Reference Types. ↩︎

  2. The term Unvalidated refers to a value that hasn't been validated yet. It could either be valid or invalid. ↩︎

  3. Such as WalkerCodeRanger/ExhaustiveMatching. ↩︎

  4. In Rust, extension methods can be implemented by defining a trait and then implementing the trait for the type. The apply crate implements a pipeline method in this way. ↩︎

  5. flow in Lodash and pipe in Ramda. ↩︎

  6. ES pipeline proposal. ↩︎