11

I am developing a utils data engineering package in python, and for the sake of reusability and readability, I chose the functional programming (FP) approach.

Assume a key task of converting data from types a, b, c to type X. I write three functions from_a_to_x, from_b_to_x, and from_c_to_x.

Is it a good practice in FP to have a to_x function such as:

from typing import Union
from mypackage import from_a_to_x, from_b_to_x, from_c_to_x
from package_a import a
from package_b import b
from package_C import c

def to_x(data: Union[a, b, c]) -> x:
    """Converts data types a,b,c to x"""
    if isinstance(data, a):
        return from_a_to_x(data)
    elif isinstance(data, b):
        return from_b_to_x(data)
    elif isinstance(data, c):
        return from_c_to_x(data)
    else:
        raise TypeError("Unknown data type")

Or, this to_x function is unnecessary and the users should themselves handle the polymorphic behavior in their application?

11
  • 3
    There seems to be two different questions from your post: 1) Is polymorphism a good practice in FP?, and 2) Is the specific implementation you provided good practice in Python?. You seem to be more interested in the second one (and the first is quite broad, but can be summarized as "there are multiple kinds of polymorphism, but yes"). Could you clarify what are your expectations regarding answers? Commented Nov 12, 2021 at 12:53
  • 7
    I'm with @VincentSavard here: the title of your question is unrelated to your actual question. There are multiple types of polymorphism, and they are very important in many functional languages. But because Python is dynamically typed, it can only support one type: ad-hoc polymorphism. And you aren't using that in your question.
    – David Arno
    Commented Nov 12, 2021 at 13:21
  • 1
    You may be interested in Python 3.10's pattern matching. For a broader context, see here.
    – J.G.
    Commented Nov 12, 2021 at 19:19
  • 1
    Fair enough. Maybe it'll help in the future, though.
    – J.G.
    Commented Nov 12, 2021 at 19:34
  • 1
    What python versions do you want to support? If you can do >=3.4, you can use docs.python.org/3/library/… Commented Nov 16, 2021 at 0:31

6 Answers 6

20

It seems to be a common cargo cult today to ask if something is "a good practice". Usually, this is the wrong question, since in programming there is almost nothing "good" or "bad" per se - it always depends. So which "cons" and "pros" do you get by implementing a function like to_x in the shown way (compared to the alternative of not providing this function at all)?

Pros:

  • it is simple
  • you don't have to care for potentially unwanted side effects by modifying existing packages, classes or objects
  • code which is implemented in terms of to_x works generically on all supported types

Cons:

  • to_x violates the Open-Closed principle, which means, whenever the conversion logic needs to be extended for another type, you have to change to_x.

Is this a problem? This depends on how much genericity your code requires. When your goal is to put to_x into a package X, your generic code using to_x lives in another package Y, and you want to be able to introduce a new type d to work with Y without changing X, then this may become a problem, especially when all those packages have different maintainers. In this case, an OO solution might be better suited. But if that's not your goal, and you just want to use to_x as some "syntactic sugar" for the conversion for a fixed number of types, then go ahead.

The OCP is not an end in itself, it is a means to an end, and for lots of real-world situations following it can lead to overengineering.

11
  • 3
    "Usually, this is the wrong question, since in programming there is almost nothing "good" or "bad" per se - it always depends." +100 on this if I could
    – TCooper
    Commented Nov 12, 2021 at 21:58
  • 7
    @FrankHopkins: when not having enough information for a full evaluation of a scenario , I recommend to use the most simple solution for a problem that works, and not something some architecture astronaut or "design patterns book writer" has declared to be a "best practice for many scenarios". So people should better stop to ask "what is the practice", but "what is the most simple, working approach", and at "at what point should I use a more complex one".
    – Doc Brown
    Commented Nov 13, 2021 at 7:41
  • 1
    @R.Schmitz I think it goes back at least as far as Edsger Dijkstra's 1968 letter ‘Go To Statement Considered Harmful’.
    – gidds
    Commented Nov 13, 2021 at 12:41
  • 1
    @R.Schmitz, and giidds: I agree to both of you, I can remember to have seen such line of thinking by myself >30 years ago. What I really had in mind here was the fact lots of askers speficially on this site seem to be in search for a "best practice" which can be applied without thinking about the context.
    – Doc Brown
    Commented Nov 13, 2021 at 18:52
  • 2
    Although I appreciate your statement regarding the correctness of a question on what is a good or better practice, doing the simpler or faster way almost always leads to technical debt, low maintainability, and the need for refactoring. So, as a novice person in FP, I had to ask before experimenting. @Doc Brown
    – maaniB
    Commented Nov 14, 2021 at 17:45
8

Statically typed functional programming languages often solve this situation with a type class. For example, Haskell has a Show that converts type instances to a string, similar to your to_x if x was a string. The nice thing about type classes is you don't require one central function that knows about all the individual implementations.

So ad hoc polymorphism is not only a good practice in functional programming, many FP languages have more expressive ways of doing ad hoc polymorphism.

However, you're not using one of those languages, so you should use the most expressive mechanism that your language provides. That means something like Caleth's answer. Object-oriented classes do not conflict with a functional style. They can work very nicely together.

Avoiding OO classes at all costs is more of a procedural style than a functional style. If that's what you're going for, that's fine, but you're going to have situations like your to_x where it doesn't seem to fit well with the language.

5
  • 1
    To be honest, Caleth's solution does look more like an ugly hack to me, to simulate extension methods in Python. The OP's code is something I understand immediately.
    – Doc Brown
    Commented Nov 12, 2021 at 15:20
  • @DocBrown def creates an object, and binds it to a name in a scope. The code in my answer takes an existing object and binds it to a name in a scope.
    – Caleth
    Commented Nov 12, 2021 at 16:20
  • 3
    @DocBrown, extension methods are the simulation. Monkey patching is the older and more powerful technique. I very much prefer static typing, and even I have a hard time arguing those three lines in his first example aren't a prime example of the benefit of a dynamic language. It can't get much simpler. Commented Nov 12, 2021 at 17:13
  • 3
    The paper that introduces type classes is literally titled "How to make ad-hoc polymorphism less ad hoc". Commented Nov 12, 2021 at 18:32
  • @Caleth: ok, I have to admit, I think the solution which confused me was the one you stripped out of the answer in your last edit. Your monkey patching approach is surely readable and simple, however, it isn't semantically equivalent to the original code.
    – Doc Brown
    Commented Nov 12, 2021 at 21:06
4

This is actually a pretty common thing to do in Python libraries. I'd say it's an approach that works in practice but not in theory. The biggest challenge it is not always obvious what types such methods take but you've mitigated that with typing hints to some level.

One thing you can do to eliminate the if-else chaining is to use a dict, like so:

converters = {
    a: from_a_to_x,
    b: from_b_to_x,
    c: from_c_to_x,
}


def to_x(data: Union[a, b, c]) -> x:
    try:
        return converters[type(data)](data)
    except KeyError:
        raise TypeError("Unknown data type")

Alternately, if you need to support subtypes, you can do this:

def to_x(data: Union[a, b, c]) -> x:
    for typ, converter in converters.items():
        if isinstance(data, typ):
            return converter(data)

    raise TypeError("Unknown data type")

The challenge with that, though is the order of the dict items might change your results.

The big advantage of this is that you can 'register' new converters at runtime instead of having to modify the code. Of course, your type hints won't be of much use if you allow for that. Personally I still prefer this over the if-else chain, all things equal.

1
  • The (possible) downside of using a dictionary of types exactly like this is that it does not allow for covariant of the type. Sometimes you want that, but often times when it comes to converters, if you have something which turns an A to an X, you can usually do the same thing to ChildOfA. Commented Nov 16, 2021 at 21:44
4

Polymorphism like this is painful in Python because it lacks function overloading.

Ideally you'd have overloads of to_x for each of a, b and c, and Union would dispatch to the correct overload when you use it, but that isn't Python.

If you want to avoid lots of isinstance, you could add to_x to each of a, b, c as a member

from typing import Union
from package import from_a_to_x, from_b_to_x, from_c_to_x

a.to_x = from_a_to_x
b.to_x = from_b_to_x
c.to_x = from_c_to_x

def to_x(data: Union[a, b, c]) -> x:
    return data.to_x()

# can call data.to_x() instead of to_x(data)

But you seem to be insistent on an isinstance approach. Might I suggest iterating your ifs

_to_x = [ 
    (a, from_a_to_x),
    (b, from_b_to_x),
    (c, from_c_to_x)
]

def to_x(data: Union[a, b, c]) -> x:
    for typ, func in _to_x:
        if isinstance(typ, data):
            return func(data)
    raise TypeError("Unknown data type")
18
  • 2
    @maaniB every value in Python has a class. int is a class, NoneType is a class etc
    – Caleth
    Commented Nov 12, 2021 at 12:20
  • 1
    @maaniB either you use the polymorphism tools that Python provides, or someone has to write many isinstance checks
    – Caleth
    Commented Nov 12, 2021 at 12:44
  • 1
    Obviously, polymorphism is easily handled in object-oriented python. The point of this question, for me, is that, considering FP principles of pure functions and having no side effects, one can achieve polymorphic behavior using if-elif conditions. However, I wanted to know whether in FP is it a good practice to have a function using those subfunctions? or the developers using this utils package should handle it, for example in their API.
    – maaniB
    Commented Nov 12, 2021 at 12:49
  • 1
    @maaniB Your code already has a great big side effect in it: raise TypeError("Unknown data type") Commented Nov 12, 2021 at 15:24
  • 3
    @maaniB It is an effect of setting up the module. It is not a side effect of calling the to_x(data) function, which is what FP is concerned with. Changing the behaviour of classes from other modules might go against modular programming practices, but it's what we're left with in Python. It does not go against the spirit of FP.
    – Bergi
    Commented Nov 12, 2021 at 23:15
3

Sounds like a job for functools.singledispatch. I just learned about type classes literally 20 minutes ago, and went looking to see if python could support them. I found singledispatch (which is almost like typeclasses, almost) and this post.

https://docs.python.org/3/library/functools.html#functools.singledispatch

This decorator allows you to write your library-specific implementations, while allowing them to be extended:

from typing import Union
from functools import singledispatch
from maaniB import from_a_to_x, from_b_to_x, from_c_to_x
from package_a import a
from package_b import b
from package_C import c

@singledispatch
def to_x(data) -> x:
    raise NotImplementedError(f"Unhandled data type: {type(data)}" 

to_x.register(a, from_a_to_x)
to_x.register(b, from_b_to_x)
to_x.register(c, from_c_to_x)

The great part about this is that it allows me, a consumer, to implement my own types, without any input from you, the library maintainer:

from maaniB import to_x

@to_x.register
def int_to_x(i):
    return whatever(i)

class MyOwnWidget:
    def to_x(self):
        return f"MyOwn({self})"
    
to_x.register(MyOwnWidget, MyOwnWidget.to_x)

If you can use python >=3.8, you can also use singledispatchmethod.

Why is this better?

To understand the advantages of this approach, we first have to briefly consider what the alternatives are. The first is inheritance-based polymorphism. If you wanted, you could write the elif isinstance tree in a OOPy way.

In python, the difference between Widget(foo).to_x() (instance method with self) and Widget.to_x(foo) (the unbound method) is very small. So as long as you control type a, you can just write a A.to_x() method. If there is significant similarity, you can often accomplish this behavior with a mixin. This is "early binding". The one great thing about this approach is that it facilitates generic interfaces and static type checking.

You can provide a AbstractBaseClass and let the user implement their own to_x but this tends to lead to the banana-gorilla-jungle problem with more sophisticated interfaces.

However, it sounds like from your setup that package_a through _c are not actually libraries you control. Hence why you end up with the function with the manual type dispatch. As Doc Brown points out, this could be fine if the set of types you wish to handle is small - e.g. you just want to deal with numpy arrays and pandas dataframes. By using runtime dispatch, you don't have to modify the actual library code. This is "late binding". This works great when you are converging on a single concrete type (say you are returning str), but it gets more tricky if the return type is parametric. Say your to_x is tasked with converting all custom types to Union[str, int, float, dict, list] so you can JSON serialize it later. But you don't know whether the root object is a Dict or List until you get your actual object. You need to essentially loosen the return type so that the type checks out. But now you have this abomination like -> Union[Dict[str, Jsonable], List[Dict[str, Jsonable]] (where Jsonable is roughly Union[list, dict, int, float, str, type(None)]. Also lets say a new type arrives and the sensible interface is to return a primitive, now you have -> Union[Jsonable, Dict[str, Jsonable], List[Dict[str, Jsonable]], it's a hot mess. It would be nice if each implementation could have its own return type, based on the input type. And as mentioned, late binding suffers from downsides relating to OCP.

singledispatch mostly avoids issues with OCP; it's not quite the same type class behavior as more FP-heavy languages since you have to explicitly register functions. But I would contend that it gives you the best of both worlds - the stronger type guarantees of early binding, but the dynamicism of late binding.

2

Ad hoc polymorphism is used in FP, but your example isn't really a case where this is the way to go.

Rather, it is a very clear-cut example of an algebraic data type. In Haskell, you'd do it like this:

import MyPackage (fromAtoX, fromBtoX, fromCtoX)
import PackageA (A)
import PackageB (B)
import PackageC (C)

data ABC = Aey A
         | Bee B
         | Sea C

toX :: ABC -> X
toX (Aey a) = fromAtoX a
toX (Bee b) = fromBtoX b
toX (Sea c) = fromCtoX c

Those multiple clauses you can have in Haskell are nothing but alternative syntax for the perhaps more familiar-looking

toX datas = case datas of
     Aey a -> fromAtoX a
     Bee b -> fromBtoX b
     Sea c -> fromCtoX c

Python's Union is essentially expressing the sum type ABC, and the isinstance cases correspond to the different case-matches / clauses. The difference is that in Python the type is always part of a value's runtime representation, so you might as well use it for the branch selection, whereas in Haskell types are normally erased at runtime but you can explicitly distinguish different cases. This has several advantages (including that the compiler can easier check correctness&completeness, as well as optimising the decisions, and you can add further special cases without needing additional types), but it does require that when calling toX, you need to explicitly wrap the value in its dedicated constructor, like

main :: IO ()
main = do
   let myA = ...
   let myX = toX (Aey myA)
   print myX

Real ad-hoc polymorphism is also possible in Haskell, done with type classes (this is perhaps surprising from an OO background); in your example it would be

class XAble d where toX :: d -> X

instance XAble A where toX = fromAtoX
instance XAble B where toX = fromBtoX
instance XAble C where toX = fromCtoX

In this case, there is no need to wrap the values, however this is in practice quite different from how your Python example is structured, because the type class is open, i.e. anybody might later on add new types that can be converted to X, whereas in your case you restrict the allowed types to exactly A, B and C – again, that is like a Haskell sum type, not like a Haskell typeclass.

3
  • awesome! I noticed you didn't raise errors, manually. How do FP langs like Haskell handle errors? Does Haskell return errors at runtime for an unknown type d? And What do you think about returning null types, in python we may return Union[x, None] or Optional[x] if we don't want to raise exceptions.
    – maaniB
    Commented Nov 14, 2021 at 11:53
  • 2
    That's one of the advantage of the ABC sum type: it's not even possible to generate a value of a type that's not listed in the data declaration – you would need another constructor with type D -> ABC, but the only constructors are those that you've already listed. If you have a variable d :: D and try to write e.g. toX (Bee d) then you'll get a compiler error, which can never surface at runtime. Commented Nov 14, 2021 at 14:50
  • 2
    ...but that's not to say Haskell never needs to handle errors. Indeed one of the possibilities is Optional, which is called Maybe in Haskell. And you can always raise exceptions (Haskell is not total, in contrast to languages like Agda), but this should preferrably only be done if some IO operation failed that you couldn't possibly have planned for, or something truely “impossible” (so you thought) has happened. Commented Nov 14, 2021 at 14:57

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