My understanding of OOP and FP

In my previous article, I explain my understanding of programming languages in general. In this article, I will explain how I view Object-oriented programming (OOP) and Functional Programming (FP).

TL;DR: OOP is good for expressing general objects, while FP focuses on some specific cases.

OOP

OOP is a very good tool to express the objects, otherwise, it won’t be so well-used, and you probably shouldn’t blame OOP for design patterns.

Visibility control is a great tool to encapsulate. Inheritance expresses the “is-a” relationship. For example, Chrome is a browser. Firefox is also a browser. Because they are browsers, they both support the operations of a browser. However, how these operations are implemented can be different. With inheritance, we can express this relationship between Chrome, Firefox, and the browser, and we can have polymorphism.

Interface expresses commonalities between classes. Interface implementation is similar to class inheritance. For example, Both a browser and a Twitter app can be used to tweet on Twitter. They may implement the same interface. Class inheritance focuses on what it is, while interface implementation focuses on what it can do.

In some languages, there may not be these concepts, but there are other ways to express the same ideas. For example, in Rust, there is no inheritance. Rust prefers composing over inheritance. The trait in Rust expresses the commonalities between types. As I mentioned in the previous article, the ability to express the idea is the key. A language doesn’t have to be OOP, as long as it is as expressive.

OOP provides a useful tool to express the objects and their logical relation. It’s a useful feature for expressive programming.

FP

Now you may ask, since OOP is so good, why do we need FP? It is because some classes are special. They are some specific cases in which the objects have certain unique structures. If we just use the general OOP features, we need to write many duplicate codes. The goal of the FP language features, in my opinion, is to help us to express some specific groups of objects close to our idea.

I disagree with either abandoning OOP or ignoring FP. On the one hand, OOP is a great general way to express objects that can be used universally. On the other hand, the special of some objects exists objectively. Instead of being ignorant which does not bring simplicity since the specific case still exists and needs to be handled, addressing it and making it expressive is a better choice.

In the following sections, I discuss the features in FP languages by explaining what kind of objects a feature tries to express. My focus is on expressiveness. So as long as a language can express a certain idea, it has the feature in my POV. However, the way a language expresses an idea can be different. I use Rust, Kotlin, Python with typing and TypeScript as examples, but obviously, these are not the only languages. The languages like F# (it has OOP!), the new version of C#, and the new version of Java may also have these features.

Option and null-safety

Many FP languages don’t have null at all. They often use an Option type to represent an optional value. It clearly expresses whether a value is optional. The compiler ensures that you consider the non-exists case when processing an option. Java, Scala[1], and Rust use this approach.

Besides, there is another approach in the languages that have null, which is null-checking, like Kotlin, Python’s static checker and TypeScript. They use a specific notation to make a type optional so that it can be null or undefined. In Kotlin, you add a ? after the type. Only under the condition that the value isn’t null, such as inside if (v != null) { ... }, you can use it as the non-null type, and call its method.

Either option type or null annotation expresses whether a value is optional clearly. It makes the language more expressive, and the type checker helps reduce the bugs of not handling non-existence correctly.

There are situations when we want to “fail fast” if the value is null. In this case, we want the program just exit if it is null. It is useful when you don’t expect the value to be optional. In Rust, you can use the unwrap method. In Kotlin, you can use the !! operator. When you fail fast, you still handle the non-existence case. The unwrap method or !! operator expresses the idea of “ensure it exists”. You make this logic explicit. It should only be used when non-existence is unexpected. If the non-existence needs to be properly handled, properly handle it.

Higher-order function and Action

Sometimes, we may have the idea “create a new list from a list by multiplying each element by 2”, or “register the action of displaying the message to the event when the user clicks the button”. The parameter of the action is itself an action, such as “multiplying by 2” or “displaying the message”. In programming, the action can be represented by a function. In FP, a function can be an object.

The language often provides a feature called “lambda expression” to create a function object. You just need to write the name of the parameters if any, then the body of the function.

The higher-order function can greatly improve expressiveness. For example, if I want to express “from a list, create a new list of the IDs of the elements that their score is higher than 60”. You may start “restructuring”: uh, I need to create an empty result list, then enumerate the elements in the list, if the score is higher than 60, then add it to the result list.

In Kotlin, with the power of the higher-order function, we can write this.

1
2
l.filter { it.score > 60 }
.map { it.id }

It’s a clear representation of my idea. Even though it still has a little bit of translation, it is much clearer.

Immutable and Value-like

We have data in real life, we have even more data in programming.
Data is a specific case of objects, which are value-like. Value-like means when operating the references of the objects, we mostly don’t need to care about which one it references, but only the value of it.

A typical value-like class is a string in GC languages, where a string is operated as a reference. But in most of these languages, we hardly care about what it references, only care about the string value. It matches how we handle string and the data in our minds: we never have “reference” in our minds, only the data value. There are two factors to make a reference value-like.

  1. Immutability. This means the values of the data won’t be changed when referenced. When an object is mutated while another reference exists, you need to care about the other reference because it can read your mutation. In most of the GC languages where you cannot control the reference, the object should not be mutated after construction.
  2. Value equality. This means, that whether two references are considered equal is based on their value instead of the reference. So that we don’t need to care about references when comparing equality.

Here are the uses of value-like data in different languages.

The integer types in Kotlin and Scala are value-like. They don’t distinguish primitive types and classes of integers. Because the classes are value-like, we don’t care about the reference when operating integers. In this case, the compiler can replace them with primitive types when possible, without the developer’s need to care about it.

The standard libraries contain functions to create value-like collections.

Rust supports control of the mutability by the borrow checker. It ensures that, if a value may have another reference, it cannot be mutated unless it is specifically allowed via interior mutability like RefCell or Mutex. This is one of the reasons I love Rust.

In Python, there is a standard frozenset function to create immutable set. For other collections, there are third-party immutable data collection libraries.

JavaScript supports control of the mutability by object freezing. There is also a third-party library, immutable-js, to create immutable data collection. Unfortunately, JavaScript does not support override equality.

Record

Records are value-like data that contain some fields, and each field has its data. You may have seen this data a lot in real life, which may be presented by a table or a list of the fields and their values.

The record feature from FP is to provide a convenient way to express a record. You can define a record by declaring each field and its type. The language provides the mechanisms to, conveniently,

  • control the mutability of the fields,
  • make the equality value-based,
  • construct a record by giving the value of each field[^4], and
  • construct a modified version of an existing record with some fields changed. The original record is not mutated.

Kotlin uses data class while Scala uses case class to define a record. In Kotlin, you can use val to make the field immutable, while in Scala fields cannot be mutable. Thanks to the named parameters, you can construct a record by giving the value of each field. They will generate equals and hashCode overrides. They provide method to construct a modified version.

In Rust a record can be defined as a struct with #[derive(PartialEq, Eq)]. The borrow checker can limit the mutation. It also has a struct update syntax.

In Python, you can use the dataclass annotation from the dataclasses module with frozen option. It will generate the __eq__ method. The dataclasses module also provides a replace function to construct a modified version.

In TypeScript a record can be defined as an object type. You can create the record by simply creating an object. You can mark the fields readonly so that the TypeScript compiler does not allow it to be mutated. You can ensure immutability by object freezing. You can use object spread to create the modified record. Unfortunately, JavaScript does not support override equality.

Newtype

Some data can be represented by a simple type, but they have their meanings and restrictions. For example, the user name. We can use a string to represent a user name. But firstly, it differs from our idea. We want a user name, and string is just its representation. Secondly, the user name has a constraint on the value.

The Newtype pattern in FP suggests creating a new value-like type to represent a user name, which contains a string.

There are several benefits of using this pattern.

  • We express the idea more clearly.
  • There is a clear boundary between validated and unvalidated[2] values.
  • You will get a compile error if you mistakenly use an unvalidated string as a user name.
    • Ideally, the new type should only be able to be constructed from the valid value.
  • You will get a compile error if you mistakenly use a user name as, for example, an email address, even if they both are represented by a string.

In Kotlin, you can use data class to define the new type. You can perform the validation in the init block, throwing exception if it is invalid, in which way it is guaranteed that the new type instance has the valid value. Alternatively, you can define a validation function that validates the value and returns the new type instance. The benefit of this approach is that the invalid error can be represented other than exception, e.g. Result<T>.

Alternatively, you can use value class. Beside validating in init block, you can make the constructor private and provide a validation method in the companion object. (Note that if you use data class, you can always construct an instance with copy method even the constructor is private).

In Rust, you can define a struct with a single field. If you want to ensure the validity, you can make this field not public and provide method to obtain the reference or into the value.

In Python, you can use the NewType. It’s not guaranteed to be valid, but the type checker can prevent you from mistaking an unvalidated value as the new type.

In TypeScript, you can use the branded type pattern. It’s not guaranteed to be valid since you can always as in TypeScript, but the type checker can prevent you from mistaking an unvalidated value as the new type.

Algebraic data type

Some data have several cases, and each case may have its own content. When processing the data, we may want to have different code paths for different cases, and the code can access the content of its corresponding case. The Algebraic data type feature (ADT) in FP is to express this data.

You may notice the “is-a” relationship between the data and its cases. You may want to express this data as a class, the data processing as a method. Then, each case inherits the data class and overrides the processing method, and implements the processing for this case. However, for data, it is common that cases of the data and the contents of each case are known, while how the data will be processed is extensible.

There is an “Open-closed principle” of SOLID. It says that a class should be open for extension but closed for modification.

In Rust, the enum can naturally express this data. In TypeScript, we use a union type with a tagged field.

To process the data, Rust and Scala provide the pattern-matching method, which is the standard feature in FP. Using pattern-matching, you can write the code path for each case of the data, and the content of each case is bound to a value local to its code path. Pattern-matching provides exhaustiveness checks, which ensure that all cases are covered.

In fact, the Option type in Rust and Scala is ADT. It has two cases: existence (often named Some) or non-existence (often named None). Only the Some case has a value.

In Kotlin and TypeScript, you can express this processing logic with smart-cast. Under the condition of the value being a case, the value will have the type of that case and you can access the content. Both languages also have exhaustiveness checks.

If exhaustiveness is not naturally supported, there are some patterns to achieve it, such as the Visitor pattern and the Not pattern. However, they are less expressive.

Based on ADT, we can define a type representing either a success value or a failure. It can be used for error handling alternative to the exception mechanism in some languages. You can find my understanding of error handling in this blog.

Conclusion

In this article, I explain my understanding of OOP and FP. I consider OOP as a general way to express the objects while FP for some specific cases.

You may notice that I haven’t mentioned category theory, the monoid in the category of endofunctors™. I will write a blog about this. Spoiler: I’m against using category theory in programming.


  1. Java and Scala have null. So they are not technically null-safe. But option type can make it better. ↩︎

  2. Unvalidated means the value that has not been validated. It can be valid or invalid. ↩︎