1

Quite often I find difficult to decide between implementing operations as functions or as methods because I am not sure how to weight various well-known guidelines for this problem. I would like to know if someone could provide further design insight to help me decide one over the other.

To illustrate my point, I will show a scenario representative of some cases where I have experienced this difficulty. The example is in Python, but I it could apply to other languages as well.

Suppose I have some immutable data,

@dataclass(frozen=True)
class Patient:
    age: int
    height: float
    weight: float
    drinks: bool
    smokes: bool
    ...

and now I want to implement some operation, for example,

def calculate_diabetes_risk(patient: Patient) -> float:
   ...

This operation could be implemented as a method of Patient as well. At this point, I am thinking in the following arguments,

In favor of the function:

  • If done otherwise, we will end up with a class with two methods, and one is __init__ (behind the decorator). Many people will probably point to the famous talk Stop Writing Classes that advises against it.
  • The favored answer in this (very related) question suggests that methods are better suited when the action is performed by the object and not on the object, which is the current case.
  • Semantically, I often heard that functions are more adequate for modelling procedures or algorithms while methods for behaviors.

In favor of the method:

  • The operation is specific to this type of data; a method will make more explicit this strong association.
  • No need to pass the argument when calling the function.

At this point, I usually favor the function unless I need some class features like mutable state, inheritance, polymorphism, etc. For simplicity, let's assume I don't (which is my most common case).

My problem starts when I begin adding more similar operations, for example

def calculate_heart_disease_risk(patient: Patient) -> float:
    ...

def calculate_life_expectancy(patient: Patient, disease_history: DiseaseHistory) -> float:
    ...

...

At this point I often get the suggestion to change these functions into methods of the Patient class, often based on the idea that

  • all of them share Patient as argument, and this strongly hints that they belong together
  • changing the class name will not involve renaming the parameters in all the functions

I think these points have merit, but a few questions pop up in my mind

  1. Was my initial decision flawed and should have gone with the method since the beginning? If yes, how can I better recognize when I should do this? Or, is the method always the right choice for this scenario?
  2. Continuing in this way it is easy to end up with a single class with many methods. I think this is not a big issue because one can reason independently on the methods (we only have immutable attributes, no state) but I have heard some concerns that Patient will become a god object. Is this something I have to be careful of? If yes, how can I prevent it?
2
  • “No need to pass the argument when calling the function.” I disagree. person.calculate_diabetes_risk() is essentially the same as calculate_diabetes_risk(person). You have to specifiy which Persion object in both cases, just the syntax is different – besc Nov 28 '20 at 8:44
  • @besc Essentially the same, but by definition person is used as an object here and not as function parameter. There's a practical difference when reading it, too: It shows the importance of person compared to the other parameters. For example, every other argument could be optional. – R. Schmitz Nov 28 '20 at 9:02
2

"Quite often I find difficult to decide between implementing operations as functions or as methods because I am not sure how to weight various well-known guidelines for this problem."

On it's own, it doesn't really matter all that much. If you're more inclined towards the OOP style, you'll tend to prefer methods, otherwise, you'll think more in terms of functions and passing data around. Both are fine. Functions vs methods is not the real issue. The core issue is how you represent the problem domain (and its subproblems) in code - how conceptualize the problem and express these concepts in code, how you decompose things, and how you control interrelationships between things.

" * The favored answer in this (very related) question suggests that methods are better suited when the action is performed by the object and not on the object, which is the current case."

This is a matter of perspective ("by" vs "on") - i.e. it depends on how you think about the problem. After all, as SE user besc pointed out in a comment to your question, "person.calculate_diabetes_risk() is essentially the same as calculate_diabetes_risk(person)". The by/on dichotomy doesn't really help.

" * Semantically, I often heard that functions are more adequate for modelling procedures or algorithms while methods for behaviors."

Same problem. Without defining what the essential difference is between procedures and behaviors, this doesn't really help either.

Plain data structures, functions, and objects can work together, though, and they can complement each other nicely. They all afford different kinds of abstractions that let your code be more expressive. Data structures let you model (represent) concepts as a small collection of properties. Functions let you abstract away a procedure behind a (hopefully well chosen) name, and control the coupling with other code via a well defined set of inputs and outputs. In terms of designing for change, functions are good when data structures they use are relatively stable - you can easily add functions that work with same data structures, but then changing those data structures could be a chore.

Objects bring a couple of new things to the table, and also invert this dynamic. They are like tiny computers that bundle a number of related methods, maintain their internal state, and enforce rules governing state changes (or, if immutable, enforce those rules across copies). They can be substituted for each other if they implement the same interface. Objects are good if the interface in question is more stable compared to internal representations of implementations - it is easy to add a new representation, but hard to change the interface.

Finally, objects have constructors, and this is neat because they let you preconfigure them, and pass these preconfigured instances around. You can sort of do the same thing with partially applied functions, lambdas, or closures - in this light, you can also see objects as a more versatile version of that.

Conventional OOP wisdom would tell you to look for nouns in your problem domain, as these are likely to be good candidates for objects. E.g. maybe Patient should be an object. But this is not the only way to do OO. Sometimes, these concepts that we find at the beginning are better represented as data structures that are passed around. Instead, objects come up later on, as your understanding of the domain improves. Maybe things that manipulate these data structures are better represented as objects. Maybe a some computation or behavior can be more elegantly represented if it's associated with a computation-specific state.

"Was my initial decision flawed"

Yes. But here's the kicker: your initial decision is always going to be flawed to some extent. That's essentially one of the defining features of our discipline.

"and should have gone with the method since the beginning? If yes, how can I better recognize when I should do this? Or, is the method always the right choice for this scenario?"

No. You don't magically find a design at the start that will then somehow turn out to work well for every change and new development that comes along. We have a name for that idea - waterfall. See also Big Design up Front. Unless the problem you're working on is very well understood1, the waterfall approach doesn't work, and even then there will be aspects surrounding the problem that aren't waterfall-y.


1 "well understood" - What I mean by that is: the rules governing the problem are understood. There's a body of documented knowledge and experience surrounding the problem. E.g., maybe there's math describing (aspects of) the problem, there are standards, published data about things like tolerances, experimental support, etc.


You came up with a design that seemed reasonably good at the time, based on what you knew about the problem at that time. Sure, if you happen to work on a problem that has aspects that are familiar to you, you may come up with a better educated guess about what the design should be. And with experience, you'll be able to recognize certain features of the problem more easily. And maybe for a certain class of problems you can start with a cookie-cutter solution (e.g., some starter skeleton code with an accompanying list of "best-practices").

What you did initially is fine. What you do as things progress is more important. Because over time, things are likely to "conspire" to make your initial assumptions, and your initial design, less and less suitable. That's what agile is all about - learning on the go, and responding to this new knowledge. The problem is that people forget the "responding" part.

"but I have heard some concerns that Patient will become a god object. Is this something I have to be careful of?"

It will, but only if - (1) that part of the codebase gets worked on a lot, and (2) if the developers working on it don't stop and reevaluate the design when they sense there's friction. You can't be agile by being rigid.

This phenomenon is called code rot. Now, people usually say that an important factor that drives code rot is that over time, due to things like deadlines, etc., we tend to sacrifice design and turn more towards workarounds and hacks, but I don't think that's the case. The problem is that we never reevaluate the initial designs, and then work within the constraints imposed by it.

You see, large, bloated spaghetti functions, classes, modules, etc., get built by doing the same thing over and over. By following previously established principles over and over, well past their expiration date. You'll see this kind of thing in codebases that have existed for some (not necessarily very long) time: a 10,000 line file will have a bunch of functions (or methods), many of which follow the same pattern (e.g. they take arguments in a certain order, where arguments play a certain role, and many of these functions internally do conceptually similar things, but there's no real way to DRY them out). And it's because people kept doing things the way they were done initially, without ever stopping and coming up with a different abstraction that will let them re-express their code in a simpler way. Heck, there may be even practices put in place (like code reviews) that actively discourage straying from this established path.

This is often done with good intentions - in an attempt to be professional, to maintain a certain level of quality, to do things "by the book". The problem is, the metaphorical book was written at the very start when a lot was unknown, so it has to be updated from time to time - and, initially, quite a bit more often than you'd think. This is why agile has iterations, sprints, retrospectives. Do a small number of things, get feedback as soon as possible, learn from it, update your understanding of the problem and reevaluate the design, then do it again, and again.

"how can I prevent it?"

Literally by going: "Oh. This class is getting a little too big and complicated for what I'm trying to do [or, if in a team: for what we're trying to do]. Let me see if I can somehow split or rearrange the code to make things simpler, based on what I know about the problem domain now."

An important point is that this is not just about splitting a big class into a couple of smaller ones. Just splitting is not enough. You have to do it in a way that buys you more high level expressiveness than what you had before (good names for classes and methods, and free functions, are a big part of that). Something that lets your code (that now calls these subproblem-specific functions or classes you extracted) read a bit more declaratively. And while you're at it, look for opportunities to redesign the code to better support your needs as you currently understand them (in terms of what's actually happening in the domain, what the code needs to do, and in what ways it changes most often). I.e., reconceptualize how you express things in code to better reflect how you think about the problem now.

There will be parts of the codebase that don't get changed a lot. They may have a horrible design, but they work, and since they don't experience change often, their design is really not that important. It's where you keep changing things where design matters. The problem is, people rarely think about the design while they are changing code; it only becomes an obvious problem when it's too late - when you end up with that one huge file that needs to be updated fairly often, but that everybody is afraid to touch because its hard to understand and changing it is likely to break things in bizarre ways.

4

Like the SO discussion, the answer to this question is more opinion based. Both ways work, but here are some points you probably need to consider for the clients.

calculate_diabetes_risk only applies to Patient object rather than explicitly passing weight, age, height, .... to it. So the client has to create the Patient object anyway in order to calculate diabetes risk, so the object and function are just coupled.

On the other hand, why do clients need Patient object? Other than calculating diabetes, do they need this object for something else? If they do, they can always inherit the class with CustomizedPatientDataProcessing(Patient), and in the class they create their own methods. They can also override the existing diabetes calculation for their usage. Then it sounds more straightforward to choose method over function.

2

No right or wrong

Both ways work, so both can be used. The decision between them is not one of wrong or right, but more of personal preference. You also see this in the answer you mentioned: The very first sentence starts with "My general rule is...". So, there will only be arguments for either side, and the ones you listed are already pretty good.

Functions

I will just tell you that favouring functions worked for me - meaning that I never ended up with spaghetti code or god classes, ever since I worked that way.

It basically comes down to following the single-responsibility principle - you'll just have to realize that "holding data (in a certain format)" is a single responsibility, whereas e.g. "being a patient" is not. And just to be thorough, let me explicitly mention that "calculating a patient's heart disease risk" and "calculating a patient's life expectancy" are also 2 separate responsibilities.

However, notice that this is not an absolute "functions yay, methods nay" stance. Look at a list: It's a data holder and has methods like append(x), insert(i, x), remove(x). Those methods are there to serve the single responsibility of enforcing the "certain format" a list has.

Practical effects

For me, it even seems like a logical choice if you consider that code grows organically. Whether add features to a legacy application, or writing an application from scratch, you are adding functionality. And that happens by adding code, and that code needs to end up somewhere. And if you put things in the same module, you couple them.

You yourself even give me a perfect example to demonstrate this: You needed the functionality calculate_heart_disease_risk, and you have that now. Now you need calculate_life_expectancy, too. I presume the latter needs the former. If you do it the method way, you now have 3 parts that are coupled. You can not get the heart disease risk without also having everything you need to calculate the life expectancy.

It's really easy to think further - if you later also need a print_patient_data that does some nice formatting to create a PDF document, you would need all the things necessary for this, too, in order to use calculate_heart_disease_risk.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.