[go: up one dir, main page]

Noel Rappin Writes Here

Better Know A Ruby Thing: On The Use of Private Methods

Posted on June 22, 2024


Last time around, we got to Better Know access control in Ruby, and I started to write my opinion on the use of private methods in Ruby, but my position/argument/rant had gotten out of hand and so I spun it off into its own post.

This is that post.

It’s long enough as it is, let’s just get to it, we’ll skip the internal ad.

What I think about Private Methods in Ruby

In Ruby, a method without side effects should be public.

You should only have a private method if there is a side effect such that the object will end up in a bad state if it called at the wrong time. (Also, you should try to avoid having methods that are dangerous if called at the wrong time…)

You are already arguing with me.

To be clear, lots of very smart people disagree with me about this.

This is the way I actually work in Ruby on projects that are fully my own. (Which, I admit, makes them small projects almost by definition).

It’s also consistent with a lot of my other beliefs about Ruby programming, which is that I tend to be more confident in Ruby’s dynamic tooling and probably more willing to accept the occasional problem to keep the code as flexible as possible.

That said, it’s not really my nature to make bold contrarian statements (I made exactly one in the Pickaxe book and immediately softened it after four reviewers commented on it in the initial review…) And I can feel myself immediately wanting to hedge my bets.

A Slight Hedge

Here’s a slightly less extreme version:

There are two different ways to approach access control:

  • Public-first Make as many methods as possible public, only make one private if there’s a clear reason.
  • Private-first Make as many methods as possible private, only make methods public when they are actually needed by other objects.

The hedged position is that, in Ruby, you should have a public-first attitude. (Other languages have different tradeoffs.)

A clear reason for privacy might be a dangerous side effect. Wanting to keep a minimal public interface might be a clear reason if you are writing a library or gem.

The costs of making a method public vary a little bit depending on what you are doing. If you are writing a gem, and are going to have a lot of users whose code you don’t control, then the case for using private methods to managing your API is much stronger than it is for, say, a random service object in your Rails app, where all the collaborators are potentially under your control.

A More OO centric way of looking at it…

Object-Oriented design emphasizes the idea of encapsulation – an object should separate its public facing interface from its private facing data.

I agree with this idea but would say that, in Ruby, most of the time, the fact that your instance variables are private provides the encapsulation that you need. You don’t usually need to augment that encapsulation with private methods.

A Sort-Of Concrete Example

I think that it is easy to miscalculate the costs and benefits of making a method private.

Let’s take a concrete-ish example: I start with a service object of some kind, maybe it’s getting called by a Rails controller. It has an initialize method and a single public method:

class ServiceThing

  def initialize(params)
    # things happen; variables are set
  end

  def perform_service
    # things happen; calculations are made
  end
end

In the fullness of time, perform_service gets more complicated than I like a method to be and I choose to break it up. In breaking it up, I’ll often find smaller, useful abstractions. Sometimes these abstractions will just collect related bits of functionality. Other times these will be calculated properties, meaning that they are a simple calculation based on the existing data (so if first_name and last_name are instance variables, full_name and sorted_name might be calculated properties…)

The resulting service might look like this:

class ServiceThing
  def initialize(params)
    # things happen; data is set
  end

  def perform_service
    gather_data
    do_logic
    report_results
  end

  # all these methods would have bodies...
  def calculated_property

  def another_property

  def gather_data

  def do_logic

  def report_results
end

The perform_service method is clearly public, that’s the external facing interface to the class.

The question is, should any or all of the calculated_property, another_property, gather_data, do_logic, and report_results methods be private? And why?

The effect of making a method private is to prevent that method from being called outside the class. (For the purpose of argument, we’ll assume for the moment that nobody is going to try and work around the privacy.)

Costs and Benefits of Privacy

Making one of these methods private has the following benefits (I created this list from looking at a couple of other posts on the topic).

  • By keeping a method out of the public interface of the class, a user of the class does not need to understand that method in order to use the class, making the class easier to use.
  • By noting that a method is private, the author of the class knows that method can be refactored safely without affecting any users of the class, since users of the class don’t even know the private method exists.
  • By making the method private, the writer of the class is free to have the method have side-effects, because you are in control of when the method is called.
  • Making a method private is easily undoable, it’s easy to make the method public should it be valuable to do so in the future. (I actually find this argument the most compelling on this list.)

Making the method private has the following costs – this one is my list.

  • The method can’t be tested. Or at least, it can’t be directly unit tested. It also can’t be directly explored in an irb or debugger session.
  • If the private method was extracted because it was a useful abstraction, you are keeping other classes from having access to a potentially useful abstraction. In other words, you are lowering the usefulness of the class to its users.

That’s a smaller list, but I think those are significant costs, they are just sometimes hard to see because they are opportunity costs – costs of omission of functionality.

Time was that you could reliably start an argument in TDD mailing lists or wherever by asking about testing private methods. The standard response, which I’m sure I’ve told people, is that private methods don’t really represent separate behavior and that classes can be fully tested by just testing the public methods that call those private methods.

Which, fair enough, I guess, but I literally just wrote a service object where the main entry point split into like a dozen smaller bits, and I suppose if I made them all private I could deal with the combinatorial explosion of testing them all via the entry points, but that seems super-awkward and I’d rather just test them each one at a time.

My normal argument in other contexts is “it’s perfectly fine to design an object so that it’s easier to test, likely making it easier to test will also make it easier to collaborate with”, and I think that’s also is true here, if the method feels like it is important enough to need separate unit tests, there’s a good chance it will be valuable to collaborating objects.

The Value and Headache of Making Things Available

This cost-benefit analysis centers on the relative value and headache of making methods available. I think that my viewpoint here is colored by having come to Ruby from Python and (especially) Smalltalk. Neither of those languages has a way to enforce private methods (okay, Python arguably does), and people have been able to build working programs in them without the world coming to an end when they refactor.

There can be risk in keeping things public – Rails 3.0 notoriously changed a lot of Active Record internals and a lot of code that had pointed at internal methods was hard to upgrade. The methods were known to be internal, but weren’t actually blocked, so people used them.

There are kind of two ways to look at that event – you can say Rails should have been more aggressive about making things private, or you can say Rails should have done a better job of designing its internal API. I’d argue that since that time, Rails has kind of done both, there are a lot of private methods, and they came up with ways to expose the behavior that people needed to change.

You are (probably) not writing Rails, and in the case of something like the service object example, all your collaborative objects are likely in the same codebase – the tradeoff is different if you are in a library where you’ll have a lot of unknown users, then if you are in a Rails app where you can find all your downstream usages. The refactoring risk is much smaller in the second case.

So, here’s how I look at it:

I think a key point in how I look at this is that if I’ve extracted a method in my service class or whatever, I’m doing so because that method represents a useful abstraction. I’m not doing it on a whim or because I like smaller methods. (I do like smaller methods, but that’s not why I extract something.) I extract something because the extraction is useful.

Then, I think, if the extracted method is useful to me in this class, it is also likely to be useful to users of this class. So my inclination is to make it public. Especially if the method is simple and has no side effects – those calculated properties in my fake class have a really good chance to be useful, have no side effects, and are somewhat unlikely to be refactored out of existence. They should be public.

The extracted functionality steps – gather_data and so on – are trickier, because they possibly do have side effects (they might update internal instance variables). My tendency is to try to design without side effects, or with idempotency so that if the step is called again it doesn’t matter, but that’s not always possible.

I would consider making these methods private if:

  • The class has a specific other meaning for public methods, like a Rails controller or generator. In both cases, making a method public makes it something more than just a method, so in those cases I’m more likely to use privacy.
  • There is direct reason to believe that calling the method at the wrong time would cause problems. This doesn’t happen to me often, but I could imagine a class that interacted with a billing service, or which was doing some other thing with external state where you’d really want to lock down the context in which a method is called.
  • I have unusual reason to believe that I don’t want to maintain this method in a public-facing API. Again, this doesn’t happen to me often, but I can imagine a case were there was a system metaphor that I really wanted to keep clean, or a case where I knew there would be multiple users in other code bases that I don’t control. Again, this would for me go beyond just “this makes the class harder to maintain” – if I think the method is useful, it’s worth the cost to maintain.

There’s one other thing I just want to touch on, which is that if you have a series of private methods that are all manipulating the same data, at least consider the possibility that you actually have a separate class waiting to be extracted.

Of course, nothing is private

In Ruby, nothing is really private, you can access private methods with send. This kind of upends all my arguments:

  • You can unit-test private methods with send. It wouldn’t be my first choice, but the option is there.
  • People can get around your privacy. I don’t think this affects the refactoring issue – if somebody is calling send on an object to get at a private method, presumably they know the risks. I do think it affects the safety issue, since you can’t depend only on privacy to keep a method in a safe state.

Overall, I think the existence of send makes me less likely to make a method private, but really, it flattens all the arguments in both directions by making privacy a less consequential choice.

Winding Down

A lot of people feel pretty strongly about this topic in the other direction.

Leading to the question: why are we coming to different conclusions?

  • I could just be wrong. It wouldn’t be the first time.

  • I tend to consider the cost of making a method private as somewhat higher than other people do. This is a general frustration I have – there’s a strong tendency to assume that being strict doesn’t have costs. But not being able to test a method, not allowing collaborating classes to use a method – those are real costs.

  • I tend to consider the benefit of making a method private as somewhat lower than other people do. In my normal style, it’s rare for me to have a method that’s actually dangerous to call out of order. In my normal day to day, most of my code doesn’t have dependencies I don’t control. As a result, I don’t get much value from limiting my API, or from preventing refactors.

  • Also, my experience is that when genuinely valuable features are private, developers will work around the privacy if they need the feature, lowering the benefit of making things private.

  • I think that my pre-Ruby experience being substantially in languages that don’t enforce privacy makes me more confident that large programs and ecosystems can be built without strict access control.

  • I also think I have a lot – maybe too much – faith in Ruby’s existing protections. Instance variables are private. Users of my code are hopefully running tests to see if behavior changes in any way that is important to them. If I’m writing a library, I can use version numbers and documentation to guide people to understand changes.

That’s it

If you got this far, thanks. We’ll be back next time with a more normal Better Know. (Though there is another opinion piece about static types in Ruby coming up…)


A couple things to know about

  • Programming Ruby 3.3 (The Pickaxe Book) is available in ebook from Pragmatic Press and physically from Amazon among others. I’d love it if you purchased a copy.
  • I’ve been doing videos for Avdi Grimm and Graceful.Dev. The most recent one was about endless ranges. You can get a two week free trial with the code NOELRAP.

Thanks!



Comments

Please enable JavaScript to view the comments powered by Disqus. comments powered by Disqus