DEV Community

loading...

What Functional Programming Taught Me About Object Oriented Programming

nt591 profile image Nikhil Thomas ・6 min read

Introduction

My background

My introduction to programming professionally came in the era of everybody-choosing-Rails. After a brief stint at a coding bootcamp, I was able to write some code but my understanding of "object oriented" was simply rails g model model_name. I lacked an appreciation for how much I could delegate to various object classes and assumed that I had to be constantly modifying state with imperative workflows. Code looked very much like

user = User.find(id)
user.school = School.find(school_id)
user.is_enrolled? = true
user.classes.each { |class| class.cancel }
user.classes = []
user.save

As I began to explore more interactive web applications, that pseudo-OO style of highly imperative code with primitives became harder to maintain.

for(let event of eventList) {
  for (let org of event.organizations) {
    if (currentUser.organizations.includes(org.id) {
      // some DOM operation
    } else if (some other clause){
      if (this other thing is true) {
        // some other DOM operation
      }
    }
  }
}

A sufficiently large code base with lots of people working, with different features developed at the same time can get unwieldy. A coworker at the time was an enthusiast of Clojure(script) and we began using RamdaJS as a functional utility library and ImmutableJS for our data structures. Immediately I felt more in control and thought to myself "Wow, object orientation is terrible! This makes way more sense to just operate on data!"

Of course, as time went on I began to get too clever and focused on functional-at-all-costs.

compose(
  length,
  curry(someNativeFnForLists),
  map(someDataTransformation),
  filter(somePredicate),
)

This might be straightforward in a blurb (kinda...) but again, imagine doing a code review in Github after hours of feature development under tight deadlines. Does this convey intent to the reader? I was so concerned with using reduce and map because I thought Lisp-inspired FP was what mattered that I threw away the real goal, immutability and clarity of code.

I've been bouncing back and forth between the styles, slowly narrowing down on some shared set of values that allow me to write useful, easily-changed, quick-to-understand code. It turns out that there exists a family of languages that provided me the tools and framework needed to think about problem-solving.

Inspirations

The ML-family of languages ("meta-language", not "machine learning") is a functional school of thought with expressive types. Languages like OCaml, Haskell and Elm (and to some extent - Rust and Swift) are built with the expectations of fast compilers with many customized types, moduluar code with separation of boundaries, and immutability of data.

The first place this made sense was watching Yaron Minksy's talk Effective ML. Watching him take a blob of data representing a connection, seeing some "option" data in his types and quickly tearing them out into concrete types with known data made a ton of sense.

The second place I began to see this, but from an more object-oriented perspective, was Gary Bernhardt's Boundaries talk. In it, he describes pushing his mutation to the boundaries of his code and having as much of a functional core with an imperative shell to communicate with outside systems.

All of this, combined with coding in various languages and making many mistakes led me to start thinking about how to write code in a way that both OO and FP styles are trying to solve. But to get to that, I need to know what I care about.

Coding Values

TDD or test after? Static types or dynamic? Compiled or interpreted languages? Mutable or immutable? These are binary questions people ask (and are asked in interviews) where I think the answer fails to get at the crux of the matter. TDD isn't about tests. It's about confidence that your code solves the problem you want. Static types don't exist because some programmers hate the flexibility of duck-typing. Rather, it's about a formalization to the compiler of your assumptions and states of the world.

Since I write mostly enterprise software, I do care about performance and metrics than can be quantified in time. But my biggest wins seem to come from more qualitative metrics. Was a feature easy to implement? Are two functions very tightly coupled - that is, does changing function A break the tests of function B? Am I confident that adding a new function call doesn't break a call site elsewhere? I know I'm working in a garbage-collected environment, and there's a pretty good chance that the biggest performance hit to the user comes from the amount of work the essential complexity that is enforced by my problem domain.

My values are confidence in my code, clarity of intent to another programmer, comfort in changing the code for future features and much more. Knowing this, I've been able to take what I need from FP to write better OO code when needed.

What I've taken from FP

Types are cheap

This is heavily inspired from writing OCaml code. In OCaml, it would be very straightforward to write some code like

module Math = struct
  type shape =  
    | Circle of float
        | Rect of float * float
        | Triangle of float * float

  let get_area x : shape = match x with 
    | Circle radius -> radius ** 2.0 *. Math.pi
    | Rect (w, h) -> w *. h
        | Triangle (base, h) -> 0.5 *. base *. h
end

and let the compiler help make sure that every time I have a shape type, I'm handling the correct variants. In the old Lisp-inspired JS days, I might have written entire functions and workflows so that my triangle state never ended up entangled with my rectangle. But the ability to create types inside a module to logically separate my cases.

This cheapness of classes should be embraced. I'm probably not persisting Triangles in my database, but it's a perfectly useful way to hold onto my data inside the Math module so I should just have these classes where I need them. I wish Ruby had private classes scoped to modules, but the essential idea remains the same.

module Math

  def calculate_area(shape)
    shape.get_area
  end

  class Triangle
    def get_area
      base * height * 0.5
    end
  end

  class Rectangle
    def get_area
      width * height
    end
  end

  class Circle
    def get_area
      radius ** 2 * 3.14
    end
  end
end

More Types Yields More Refactorings

Specifically, the Replace Conditional With Polymorphism and Replace Primitive With Object / Primitive Obsession refactorings from Martin Fowler are very much an object-oriented translation of the ML type tagging. Perhaps you're writing an OCaml function that takes a name and an email address (both strings) to validate a user's information.

val validate: string -> string -> bool
let validate email name = (* somethingHappens *)

You could very easily accidentally swap the others around in a call site by accident and the compiler wouldn't be able to help you with that - after all, strings are strings. But what if you had small types to tag the data?

type email = Email of string
type name = Name of string

val validate : email -> name -> bool
let validate (Email emailAddress) (Name userName) = (* something happens *)

Similarly, you can use this idea to just make small structs with tests in object-oriented code. Yes, you lack the compiler guarantees in a dynamic language but you can convey intent better to your co-authors of code who are then less likely to make that mistake of swapping parameters.

Create new instances as much as you want

Just because a class instance can modify its internal state doesn't mean it has to. It's just as easy to calculate the new state and create a new instance of your object if you create classes as structs over data.

User = Struct.new(:name, :email) do
  def update_email(new_email)
    User.new(name, new_email)
  end
end

user = User.new("test", "test@email.com")
puts user
puts user.update_email("updated@email.com")

Encapsulate your data with a set of methods that describes behavior, create new instances to your heart's content, and push mutation as far away from core business logic so you know exactly where it happens. You might have a UserUpdater that takes a user and persists in the database, or a PageRenderer that handles drawing pixels (effectively React's concept of declarative programming), but your core objects can just take messages and pass around new objects.

Takeaways

I think my biggest takeaway from thinking about all this and everything I learned is that object oriented code and functional programming styled-code aren't these huge dichotomies. You don't need to pick a side. There are no winners or losers here. Everyone is trying to express intent and wrangle complexity over software as best as they can. Find the amount of expressiveness that lets people easily update their code. Classes can serve as ML-style types for the reader - humans aren't compilers, but if they know all shapes must have an area, that's a lot better than passing around primitives with no context. Immutability can help make sure that you know exactly what your data is representing at a given line. Ultimately, don't optimize for "the best OO style" or "the best FP style" because you read something on Twitter or Hacker News. Optimize for the person who's going to update your code after you're gone.

Discussion (0)

pic
Editor guide