DEV Community

Brandon Weaver
Brandon Weaver

Posted on

Functional Programming in Ruby – State

Ruby is, by nature, an Object Oriented language. It also takes a lot of hints from Functional languages like Lisp.

Contrary to popular opinion, Functional Programming is not an opposite pole on the spectrum. It’s another way of thinking about the same problems that can be very beneficial to Ruby programmers.

Truth be told, you’re probably already using a lot of Functional concepts. You don’t have to get all the way to Haskell, Scala, or other languages to get the benefits either.

The purpose of this series is to cover Functional Programming in a more pragmatic light as it pertains to Ruby programmers. That means that some concepts will not be rigorous proofs or truly pure ivory FP, and that’s fine.

We’ll focus on examples you can use to make your programs better today.

With that, let’s take a look at our first subject: State.

This series is a partial rewrite and update of my own series on Medium on Functional Programming. It has been modernized and updated a bit.

Functional Programming and State

One of the prime concepts of Functional Programming is immutable state. In Ruby it may not be entirely practical to forego it altogether, but the concept is still exceptionally valuable to us.

By foregoing state, we make our applications easier to reason about and test. The secret is that you don’t entirely need to forego it to get some of these benefits, and that's what we need to keep in mind with Ruby: there are always tradeoffs.

Defining State

So what is state exactly? State is the data that flows through your program, and the concept of immutable state means that once it’s set it’s set. No changing it.

x = 5
x += 2 # Mutation of state!
Enter fullscreen mode Exit fullscreen mode

That especially applies to methods:

def remove(array, item)
  array.reject! { |v| v == item }
end

array = [1,2,3]
remove(array, 1)
# => [2, 3]

array
# => [2, 3]
Enter fullscreen mode Exit fullscreen mode

By performing that action, we’ve mutated the array we passed in. Now imagine we have two or three more functions which also mutate the array and we get into a bit of an issue. In general it's not great to mutate data that's passed into your function.

A pure function is one that does not mutate its inputs:

def remove(array, item)
  array.reject { |v| v == item }
end

array = [1,2,3]
remove(array, 1)
# => [2, 3]

array
# => [1, 2, 3]
Enter fullscreen mode Exit fullscreen mode

It’s slower, but it’s much easier to predict that this is going to return us a new array. Every time I give it input A, it gives me back result B.

Has That Ever Really Happened?

Problem is, one can preach all day on the merits of pure functions, but until you find yourself in a situation where it bites you the benefits may not be readily apparent.

There was one time in Javascript where I’d used reverse to test the output of a game board. It would look fine, but when I added one more reverse to it all of my tests broke!

What gives?

Well, as it turned out the reverse function was mutating my board.

It took me longer than I want to admit here how long it took me to realize this was happening, but mutation can have subtle cascading effects on your program unless you keep it under control.

That’s the secret though, you don’t have to exclusively avoid it, you just need to manage it in such a way that it’s very clear when and where mutations happen.

In Ruby, frequently state mutations are indicated with ! as a suffix. Not always, though, because methods like concat break those rules so keep an eye out.

Isolate State

One method of dealing with state is to keep it in the box. A pure function might look something like this:

def add(a, b)
  a + b
end
Enter fullscreen mode Exit fullscreen mode

When given the same inputs, it will always give us back the same outputs. That’s handy, but there are ways to hide tricks from it.

def count_by(array, &fn)
  array.each_with_object(Hash.new(0)) { |v, h|
    h[fn.call(v)] += 1
  }
end

count_by([1,2,3], &:even?)
# => {false=>2, true=>1}
Enter fullscreen mode Exit fullscreen mode

Note: Newer versions of Ruby have the tally function which would be used like this to get a similar result: [1, 2, 3].map(&:even?).tally

Strictly speaking, we’re mutating that hash for each and every value in the array. Not so strictly speaking, when given the same input we get back the exact same output.

Does that make it functionally pure? No. What we’ve done here is created isolated state that’s only present inside our function. Nothing on the outside knows about what we’re doing to the hash inside the function, and in Ruby this is an acceptable compromise.

The problem is though, isolate state still requires that functions do one and only one thing.

Single Responsibility and IO State

Functions should do one and only one thing.

I’ve seen this type of pattern very commonly in newer programmers code:

class RubyClub
  attr_reader :members

  def initialize
    @members = []
  end

  def add_member
    print "Member name: "
    member = gets.chomp
    @members << member
    puts "Added member!"
  end
end
Enter fullscreen mode Exit fullscreen mode

The problem here is that we’re conflating a lot of things in one function:

  • Asking a user for a member name
  • Getting that name
  • Adding a member
  • Notifying the user we added a member

That’s not the concern of our class, it only needs to know how to add a member, anything else is outside the scope of that method.

At first this seems harmless, as you’re only really getting input and outputting at the end. The problems we run into are that gets is going to pause the test, waiting for input, and puts is going to return nil afterwards.

How would we test such a thing?

describe '#add_member' do
  before do
    $stdin = StringIO.new("Havenwood\n")
  end

  after do
    $stdin = STDIN
  end

  it 'adds a member' do
    ruby_club = RubyClub.new
    ruby_club.add_member
    expect(ruby_club.members).to eq(['Havenwood'])
  end
end
Enter fullscreen mode Exit fullscreen mode

That’s a lot of code. We have to intercept STDIN (standard input) to make it work which makes our test code a lot harder to read as well.

Take a look at a more focused implementation, the only concern it has is that it gets a new member as input and returns all the members as output.

class RubyClub
  attr_reader :members

  def initialize
    @members = []
  end

  def add_member(member)
    @members << member
  end
end
Enter fullscreen mode Exit fullscreen mode

All we need to test now is this:

describe '#add_member' do
  it 'adds a member' do
    ruby_club = RubyClub.new
    expect(ruby_club.add_member('Havenwood')).to eq(['Havenwood'])
  end
end
Enter fullscreen mode Exit fullscreen mode

It’s abstracted from the concern of dealing with IO (puts, gets), another form of state.

Now let’s say that your Ruby Club has to also run with a CLI, or maybe load results from a file. How do you refactor it to work? Your current class is conflated with the idea that it has to get input and deal with output.

This adds up to very brittle tests and code that are going to give you problems over time.

Static State

Another common pattern is to abstract data into constants. This alone isn’t a bad idea, but can result in your classes and methods being effectively hardcoded to work in one way.

Consider the following:

class SampleLoader
  SAMPLES_DIR = '/samples/ruby_samples'

  def initialize
    @loaded_samples = {}
  end

  def load_sample(name)
    @loaded_samples[name] ||= File.read("#{SAMPLES_DIR}/#{name}")
  end
end
Enter fullscreen mode Exit fullscreen mode

It’s great as long as you’re only concerned with that specific directory, but what if we need to make a sample loader for elixir_samples or rust_samples? We have a problem. Our constant has become a piece of static state we cannot change.

The solution is to use an idea called injection. We inject the prerequisite knowledge into the class instead of hardcoding the value in a constant:

class SampleLoader
  def initialize(base_path)
    @base_path = base_path
    @loaded_samples = {}
  end

  def load_sample(name)
    @loaded_samples[name] ||= File.read("#{@base_path}/#{name}")
  end
end
Enter fullscreen mode Exit fullscreen mode

Now our sample loader really doesn’t care where it gets samples from, as long as that file exists somewhere on the disk. Granted there are potential risks with caching as well, but that’s an exercise left to the reader.

A way to cheat this is by using default values, set to a constant, but for some this may be a bit to implicit. Use wisely:

class SampleLoader
  SAMPLES_DIR = '/samples/ruby_samples'

  def initialize(base_path: SAMPLES_DIR)
    @base_path = base_path
    @loaded_samples = {}
  end

  def load_sample(name)
    @loaded_samples[name] ||= File.read("#{@base_path}/#{name}")
  end
end
Enter fullscreen mode Exit fullscreen mode

IO State — Reading Files

Let’s say your Ruby Club has an idea of loading members. We remembered to not statically code paths this time:

class RubyClub
  def initialize
    @members = []
  end

  def add_member(member)
    @members << member
  end

  def load_members(path)
    JSON.parse(File.read(path)).each do |m|
      @members << m
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The problem this round is that we’re relying on the fact that the members file is not only a file, but also in a JSON format. It makes our loader very inflexible.

We’ve become entangled in another type of IO state: we’re too concerned with how we load data into our club.

Say you wanted to switch it out with a database like SQLite, or maybe even just use YAML instead. That’s a very hard task with the code like it is.

Some solutions to this problem I see from newer developers are to make multiple “loaders” to deal with different types of inputs. What if it’s none of the concern of our club in the first place?

If we extract the entire concept of loading members, we could have code like this instead:

class RubyClub
  attr_reader :members

  def initialize(members = [])
    @members = members
  end

  def add_members(*members)
    @members.concat(members)
  end
end

new_members = YAML.load(File.read('data.yml'))
RubyClub.new(new_members)
Enter fullscreen mode Exit fullscreen mode

Wait, isn’t this just Separation of Concerns?

The fun thing about OO and FP is that a lot of the same concepts can apply, they just tend to have different names. They may not be exact overlaps, but a lot of what you learn from a Functional language may feel very familiar from best practices in a more Imperative style language.

In a lot of ways, keeping state under control is an exercise in separation of concerns. Pure functions coupled with this can make exceptionally flexible and robust code that is easier to test, reason about, and extend.

A common point of confusion is that Functional Programming is an entirely new and independent paradigm from Object Oriented Programming, when in fact they share quite a few ideas, and often times are more complimentary than some would like to admit.

Wrapping Up

State in Ruby may not be entirely pure, but by keeping it under control your programs will be substantially easier to work with later. In programming, that’s everything.

You’ll be reading and upgrading code far more than you’re outright writing it, so the more you do to write it flexibly from the start the easier it will be to read and work with later on.

As I mentioned earlier, this course will be more focused on pragmatic usages of Functional Programming as they relate to Ruby. We could focus on an entire derived Lambda Calculus scheme and make a truly pure program, but it would be slow and incredibly tedious.

That said, it’s also fun to play with on occasion just to see how it works. If that’s of interest this is a great book on the subject:

Understanding Computation

If you want to keep exploring that rabbit hole, Raganwald does a lot to delight here:

Kestrels, Quirky Birds, and Hopeless Egocentricity

As always, enjoy!

Top comments (1)

Collapse
 
kputra profile image
K Putra • Edited

I've tried pure FP in ruby. And it was horrendous. The main problem is ruby GC.

But I still using FP paradigm in some cases. Right now I am working on some ecommerce that use rails as backend. For the calculation that involve money (influencing total payment), I make all the variable immutable by default.

You dont even have to ask about the performance. But imo in this case effectiveness is way more important than efficiency.