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. Kotlin and Scala match my understanding closely. Other languages like Rust and even TypeScript are also good if they can express the same idea that OOP and FP can.

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[1].

Encapsulation can be used to express whether an element is “what” or “how”. The user only needs to focus on “what”. They don't need to know anything related to how it is done. The class can change the “how” part without breaking the user's code. The object can protect it from unintended operation by the user.

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. Inheritance and overriding are how we express this relationship between Chrome, Firefox, and the browser.

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 an 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, 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, more or less.

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[2], and Rust use this approach.

Besides, there is another approach in the languages that have null, which is null-checking, like Kotlin 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 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 “rewriting”: 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.

In Kotlin, the integer types are value-like. Kotlin does not 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.

An expressive language should provide the features to express the value-like objects conveniently.

  • 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 major reasons I love Rust.
  • JavaScript supports control of the mutability by object freezing. There is also a third-party library to create immutable data collection.[3] However, it does not support override equality.
  • Kotlin and Scala standard libraries contain functions to create value-like collections. They also provide many useful language features to define our value-like classes.

Record

Records are value-like data that contain many 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. 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.

In Rust, a record can be defined as a struct.

  • The borrow checker can limit the mutation.
  • You cannot do an equality comparison, unless you #[derive(PartailEq, Eq)], which generates value-based equality.
  • It also has a struct update syntax.

In TypeScript, a record can be defined as an object type.

  • You can use freezing to ensure immutable.
  • You can use object spread to create the modified record. Alternatively, you can use the type-in from a third-party immutable map data structure, to store the keys and their values.
  • Unfortunately, you cannot override equality in JS.

In Kotlin, a record can be defined as a data class.

  • You can ensure immutability by using val.
  • The equality is automatically overridden to be value-based.
  • It generates a copy method for creating the modified record.

Newtype

Some data can be represented by a primitive 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 type to represent a user name, which contains a string. The new type can only be constructed with a valid value. It should be value-like.

There are several benefits of using this pattern.

  • We express the idea more clearly.
  • There is a clear boundary between validated and unvalidated values.
  • You will get a compile error if you mistakenly use a user name as an email address, even if they both are represented by a string.

In Rust, we can apply the Newtype pattern by defining a struct with one unnamed private field, deriving ParitalEq, Eq, and optionally PartialOrd and Ord. We provide the methods to access the inner value and construct one with validation.

In Kotlin, we can apply this pattern by defining a data class with one field and validating it in the init block. Or, we can define an inline class, validating it in the init block or making the primary constructor private and providing a construction method that validates the value[5].

In TypeScript, you can use the branded type pattern.

Algebraic data type

Sometimes, data may have some cases, and each case may have its 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 the cases and the contents are known, while how it will be processed is unknown and extensible.

In Rust, the enum can naturally express this data. In Kotlin and Scala, we mark the data class sealed, so that no class outside of the file can inherit the data class, making the cases of the data stable. 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 article about this. Spoiler: I'm against using category theory in programming.


  1. The “The Design Patterns” section in my previous blog ↩︎

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

  3. https://github.com/immutable-js/immutable-js ↩︎

  4. Java14's record cannot be constructed by giving the value by field, because it is lack of named parameter. That's why I don't think Java has records. ↩︎

  5. Note that this approach cannot be used on the data class because you can bypass the check by the copy method. ↩︎