Combining overloaded methods

Not that this question is of much practical usefulness, but is there a way to avoid method overloading with some type magic? Or why is it not possible?

For example, can the following definitions for f

def f(x: Int): Int = x + 1
def f(s: String): String = s + "1"

f(1) // 2: Int
f("1") // "11": String

be replaced by a single method definition? My attempts with different generic parameters and bounds either did not compile or were equivalent to:

def g(a: Int|String): Int|String = a match
  case x: Int => x + 1
  case s: String => s + "1"

g(1) // 2: Int|String
g("1") // "11": Int|String

which while having the correct value has the wrong type.

I also have a solution with type casting, but I feel this kinda defeats the whole purpose of the type system in the first place (I may be wrong here):

def h[A](a: A): A = a match
  case x: Int => (x + 1).asInstanceOf[A]
  case s: String => (s + "1").asInstanceOf[A]

h(1) // 2: Int
h("1") // "11": String

As so often, my initial response is “that’s what type classes are for” like this (Scala 2, which is in my fingers; Scala 3 would look a bit different but be conceptually similar):

trait Oneable[T] {
  def f(t: T): T
}

object Oneable {
  implicit val intOneable: Oneable[Int] =
    (x: Int) => x + 1

  implicit val strOneable: Oneable[String] =
    (s: String) => s + "1"
}

def addOne[T: Oneable](t: T) = implicitly[Oneable[T]].f(t)

println(addOne(5))   // prints 6
println(addOne("5")) // prints 51

That’s not overloading per se, although whether the difference is meaningful sort of depends on your POV. But this is how I would typically handle a problem like this in the real world, with a non-overloaded type class as the “interface” and implementations for each type it’s being applied to.

5 Likes

Copying the Match types: dependent typing example, I got

type Elem[X] = X match
  case String => String
  case Int    => Int

def spam[X](x: X): Elem[X] = x match
  case x: String => x
  case x: Int    => x

spam(1)   // 1: scala.Int
spam("1") // 1: scala.Predef.String

Unfortunately this is the best you can get with a “plain” approach. Notice there are 4 conditions that have to be met, it’s very restrictive.

3 Likes

Well done @jducoeur and @spamegg1! These small, real world examples are excellent to get a better understanding of these not-always-so-simple concepts and where to use them. In fact, a book for Scala 3 with such code snippets (also illustrating the newer assets from Scala 3) would be a great step forward. Even for the more seasoned programmers. Let’s start collecting these. :pray:

2 Likes

My understanding is that, in Scala, if you want to (speaking very loosely here!) “select a sharper / more refined one of two options in a logical OR” then you have to

  • either use match types and accept its restrictions,
  • or design traits (type classes) with type members and use them as path dependent types (so you can “select” the type member),
  • or use a library like Iron or Refined (maybe Shapeless?),
  • or just compromise with / accept the union type, forget the sharper type,
  • or use another language with more powerful, fully dependent type systems (like Idris, Agda, Lean).

I use the last two options. Probably there are other options here too which I’m not aware of.

Edit: I forgot F-bounded polymorphism, oh and type projections in Scala 2 (unsound). Also, Either (as mentioned below).

Generally speaking, “selecting a specific one” in an “A OR B” is both computationally and philosophically challenging.

Going away from the initial motivation, there is one option that was not explored here:
Using Either[A, B]
As it represents a tagged union, there is never ambiguity in which branch we are, as opposed to Int | Int for example (even though that would not make much sense here)

It has of course the downside of having a runtime cost, which these others might not

While a very different solution, I think we tend to forget it and its simplicity
(Perhaps because it also serves as a success/failure monad)

1 Like

There’s also the consideration that, while Either works pretty well for exactly two types, it starts getting kind of messy if you go beyond that. So in the real world, I tend not to find it the way to go except for very clear-cut problems like success/failure, where I’m confident that I will never want more than two types.

(The runtime expense sometimes matters, although that’s often a relatively minor problem.)

1 Like

Good point! Always end up forgetting Either :smiley:

I agree, this is definitely a place where a symbolic operator would make sense
(alongside nice accessor methods)

If you just want the right return type, you can use transparent inline methods. Inline methods mean the code is expanded at the call site; transparent means that it gets to know the actual type returned in the case used, not just the stated return type.

It won’t work well with complicated type manipulations, because it’s kind of compiler trick; if you want to maintain the type relationships, that’s what match types are for.

But a lot of times you don’t care–you just want the right return type when you use it. So I use this all the time instead of overloaded methods. If the content is too big to expand, I just use the transparent inline to dispatch to other methods.

Here’s how you would use it in your example case:

transparent inline def g(a: Int | String): Int | String = inline a match
  case x: Int => x + 1
  case s: String => s + "1"

And I pasted this into the REPL to show it works:

scala> g(2)
val res15: Int = 3
                                                                                
scala> g("eel")
val res16: String = eel1

Bonus feature! You can get around unwanted type conversions by explicitly naming the types you want to exclude and manually calling compiletime.error on those cases.

Here’s an example:

extension (inline x: Byte | Short | Int | Long)
  transparent inline def rotl(j: Int): Int | Long = inline x match
    case i: Int => java.lang.Integer.rotateLeft(i, j)
    case l: Long => java.lang.Long.rotateLeft(l, j)
    case _ => compiletime.error("rotl (rotate left) is defined only on Int and Long\nother primitive types are not promoted")

You need inline at all the places stated: on the argument on the way in, on the match, and on the method itself.

2 Likes

It’s important to note however that transparent method should be used very carefully if compatibility is an issue

If you want to be able to swap out functionality at class loading time, the transparent inline should only be delegating to regular methods, not doing any other work. Then you get the usual story with compatibility.

1 Like

It may be my lack of familiarity with typeclasses, but my reaction is that I can’t see why anyone would prefer this approach. It look’s like you just did overloads with extra steps. There’s a large boilerplate overhead with lots of confusing keyword tricks going on… – maybe scala have really benefited from a dedicated typeclass keyword instead of having you implement the idea through companion objects and implicits (no one who is not already familiar with typeclasses is going to get what this code is doing, but at least if they saw the word “typeclass” they could google it)

Is there another example I could see that typeclasses solve which are not possible as nicely by other techniques?

I once wrote a little bit about the topic it may be helpful: Polymorphism in Scala. · GitHub

But the TL;DR; is that there are two major advantages to typeclasses.

  1. They allow “extending” classes you don’t control.
  2. They are resolved at compile time based on types, thus they usually provide the kind of type relationships that you are trying to achieve here using pattern matching.

It’s not keyword tricks, it’s really pretty central to the language. And there is dedicated syntax for it in Scala 3, which was one of the main changes between 2 and 3 – that’s the given and using keywords. (I’m just not as practiced in them myself yet, so I used the Scala 2 implicit keyword.)

@BalmungSan gives a couple of reasons why type classes are a powerful tool. More specifically, the problem of “I want to return the same type that was passed in” turns out to be challenging to get consistently right in traditional OO programming (especially when subclasses get involved), but is relatively easy to handle correctly in type classes.

(See the “F-bounded polymorphism” article that @spamegg1 linked above, which is the usual explanation of this problem – it doesn’t come up super-often, but it can be a doozy of a disaster when it does – I’ve lost days trying to get the F-bounded approach working when doing OO-style code, and you usually don’t realize that you’re in trouble until you’ve already written a ton of code.)

Also, type classes are, essentially, interfaces. Note that my Oneable above is a type unto itself, which can be passed into higher-level functions; overloads can’t easily do that. That’s super useful, and comes up in real-world code all the time. It means that my higher-level code doesn’t even need to know or care what the concrete type is, just that it is something that is Oneable.

I should be clear: for your toy example, overloads are fine. But real-world code is usually far more complex – you usually find that you gradually have several different types for which you want to implement a behavior (not just two), often scattered around your codebase, so overloads wind up becoming a centralized and ugly maintanance headache, whereas type class implementations can be distributed around the code as desired.

No, it isn’t the absolute most-concise code one could wish for. But it often turns out to be more correct, more flexible, and more extensible than overload-based solutions, and that’s more important 99% of the time.

It’s by no means the only way to do things (Scala has many tools available), but when the problem is essentially “I have a behavior that I want to implement for multiple unrelated types”, it’s usually the best approach for producing solid, maintainable code.

The result is that I use overloads very rarely – they’re cute, but not very powerful and sometimes more confusing than helpful. (Indeed, I know folks who just plain outlaw them in their codebases, on the grounds that they are more trouble than they are worth.) If I want an overload, what I really mean is that I’m trying to implement the same behavior for multiple types, and type classes usually pay off in the long run as a better way to do that.

3 Likes

I don’t find that to be particularly true. I use them moderately heavily; the key is to understand when they’re the best tool for the job.

Typeclasses show their power most when you want to abstract over data, especially if you want to be able to pick anything you can get your hands on and give it the capability.

But overloads show their power most when you want to encapsulate functionality in a class that might be doing something nontrivial but you want to present an API that fluently* exposes what you can do.

(* In the colloquial sense, not the programming style sense.)

For instance, suppose you have a class whose job it is to fetch some data and compress it using a novel streaming algorithm. Having a high-level compress method that takes File, Path, URI and maybe InputStream is a splendid way to expose the functionality. There is no sensible reason to have a CanCompress[A] trait–the class’s job is to make it so you don’t need to know all the different tricks employed to get the data in the right shape with the different input methods.

On the other hand, Ordered is practically the canonical example of something you want done by typeclass, and possibly a set of different typeclasses, because if you’re sorting, all you need is a sane definition of <, and you’re good. The best way to reproducibly pass around that information, and let everyone sort using your sorting algorithm, is to define a typeclass.

So: use the right tool for the job. Typeclasses are more powerful, but not always well-suited to the task.

1 Like

I meant analogous to how in languages that had not yet implemented dedicated class constructs, people discovered clever ways to achieve the “idea” of what classes are through combining other features of the language, like JS creating constructor functions with the “prototype” blah blah balh, before JS finally added a first-class way to create classes by simply saying class. Or C++ lacking abstract methods, so they have a convention of making virtual functions that at first are set to 0 (I think? I’m not c++ programmer))

This seems like one of those moments. In Scala, this doesn’t look like “Typeclasses: the feature”, it looks like “Typeclasses: the design pattern”.

I would expect you to just have a typeclass keyword, but instead I have to create a trait, then companion object by the same name, then scatter using/implicits everywhere… To “achieve the idea” of type classes indirectly. Is it like this in all languages, or are there FP languages that make the notion of type classes feel more built-in to the langauge? Like… the word “typeclass” does not even show up in your code. How would I know that’s what you’re doing? Unless I also had personally memorized the ceremony and tradition.

Scala has a slightly more general and powerful feature than typeclasses that exist in other languages. The key question about typeclasses from a conceptual perspective is whether they are unique (sometimes called “coherence”).

The easy-mode answer to the question (for both you and language correctness) is that either a type does have a trait (typeclass) implementation or it does not. It’s a little bit less easy because you have to figure out some way to enforce that there can be only one, but given that there can be only one, it’s pretty simple to think about.

However, this also fails the Ordered test! For instance, Rust has coherent trait implementations, which means that all its sorting routines have to resort to ridiculous antics like wrapping everything in newtypes just because you want to sort them in the opposite order.

Scala’s allowance for there being multiple possible implementations depending on context, with a sophisticated method for propagating and computing context, is much more powerful.

But it’s not, at that point, just typeclasses. You can do typeclasses with Scala traits and context. Occasionally there is talk about supporting them specifically, and the pushback is always: but does that really help anything?

There is a little bit of support. You can write def sort[A: Ordered](xs: Seq[A]) where Ordered is not the type of A but rather a declaration that a context parameter of type Ordered[A] is expected to be found. It acts very much like a type, so it looks very much like a type.

But there isn’t really any need for a declaration that something is a typeclass because, really, it’s just a trait that has a generic type parameter. So, yes, it’s kind of a design pattern in that sense. But that doesn’t mean that it’s substandard in any major way–just that it doesn’t make much sense to have separate syntax for a strictly weaker feature if you already have the more powerful feature.

3 Likes

Are you not entertained with this?

def inc[A <: Int | String](a: A): A = {
    a match {
      case i: Int => (i + 1).asInstanceOf[A]
      case s: String => (s + "1").asInstanceOf[A]
    }
}

Yes. It’s culture / tradition. (Just like almost all things humans do)

“Type class” is not a strictly, formally defined concept, it’s loose, think of “type class” as the “folklore name”.

The somewhat more proper name is “ad-hoc polymorphism”. This is also not strictly formally defined, and type classes are only one way of doing it (there are other ways).

It also does not help that multiple ideas get all tangled up together often: “interfaces”, “implicits” and “typeclasses”. You have to know each idea separately and clearly to understand how they are fused, so you don’t get confused. (I’ll let myself out :smiley: )

More generally, almost all things in programming languages are loosely, culturally defined; and different languages implement them differently, with different keywords. Very similar to natural languages if you think about it (dialects, same word meaning different things, etc.)

More or less the same in other FP languages yes. None of them have the “typeclass” keyword.

Haskell calls it “class” A Gentle Introduction to Haskell: Classes Haskell also has the “type” keyword for type aliases (like Scala) and “datatype” keyword for defining actual data types.

Lean also calls it “class” following Haskell very closely Overloading and Type Classes - Functional Programming in Lean

Standard ML has no such keyword, but they implement the idea of an “interface” via the “signature” keyword, an example: The STRING signature They don’t have “typeclass” or “class” but “structure” instead :woman_facepalming: They also have the concept of “functor” (which is often associated with typeclasses) but no keyword for it SML Basis Library Overview

And it goes on.

Expectations are a bad idea when it comes to programming languages… I advise against it :smiley: Unfortunately it’s a mess just like natural languages, you have to learn the stupid idiosyncrasies.

I went through the FP tradition like this: Racket → Standard ML → Haskell → Scala → Lean

2 Likes