Type classes vs interfaces

What do you know about my lack of design tools and idea?

I’ve been doing OOP for 15 years and FO for 10. I moved from OOP to FP for a reason and I remember at the time trying to implement all those OOP concepts which I thought were general until I realized they were not needed (or best avoided if possible).

I know the concepts you are defending, i was defending them 10 years ago.

I hope we can avoid making personal comments and stick to objective facts. Some more examples of OOP things that can’t be done in FP would be useful, as would objective arguments about why you shouldn’t want to do those OOP things (if any).

7 Likes

@graninas can you send me the article in russian? I am learning it ATM, so maybe I can read it.

1 Like

What’s wrong with this:

class Monad m => CookingMachine m where
    makePizza :: PizzaRecipe Pizza -> m Pizza
    makeSandwitch :: SandwichRecipe Sandwich -> m Sandwich
    makeRandomMeal :: m Meal

mySandwich :: SandwichRecipe Sandwich
mySandwich = undefined

makeSandwichMeal :: CookingMachine m => SandwichRecipe Sandwich -> m Meal
makeSandwichMeal = undefined

sampleCookingMachine :: CookingMachine m => m [Meal]
sampleCookingMachine = do
    sandwich   <- makeSandwichMeal mySandwich
    randomMeal <- makeRandomMeal
    pure [sandwich, randomMeal]

? Full code.

I’ll see what I can do; it’s just not easy to explain. I have an article in Russian about why type classes and existentials can’t represent the notion of interface, but it’s outdated (year 2012?).

Could you please share the article anyway?

Sure!

It was this article: Дизайн и архитектура в ФП. Часть 2 / Design and architecture in FP. Part 2.

It was a series of 3 posts I published in 2014. Not well-written, but it was definitely the start of my research on Software Engineering in Haskell.

1 Like

@tomjaguarpaw @effectfully I need to think about what you’re proposing. Not sure for now

2 Likes

I’m having trouble seeing what this would look like in an OOP language. Are you imagining something like

interface Recipe {
  Ingredient[] getIngredients();
}

class PizzaRecipe implements Recipe { ... }

class CookingMachine {
  Meal cook(Recipe r) { ... }
}

abstract class Meal {
  // what goes in here?
}

class Pizza extends Meal { ... }

If CookingMachine returns some Meal, how is the caller expected to use the result? Does Meal have some functions that all Meals implement, or will the caller use reflection (e.g. instanceof) to dynamically dispatch?

If I were to implement this in Haskell, I’d do something like

data Recipe = Recipe
  { recipeName :: Text
  , ingredients :: [Ingredient]
  }

pizzaRecipe :: Recipe
pizzaRecipe = -- some dsl for building the recipe

cook :: Recipe -> IO Meal

-- if Meal will be inspected after, caller can
-- inspect recipe name
data Meal = Meal Text

-- if every Meal has functions
data Meal = Meal
  { getSpiciness :: Int
  , consume :: Person -> IO ()
  , ...
  }
1 Like

First I would like to say that I am probaby have a anti-OOP biais but not because of lack of knowledge, but because I’ve used both OOP and FP professionally for about 10 years each and if thought that OOP was better than FP I wouldn’t be there …(I’m not saying I’m an expert in both, just that I am (or was) as knowledgable in both).

As started off my carrier with C++ selling to people OOP concepts as encapsulation, interface to reluctant people telling me that C was enough and putting functions into structures didn’t change anything (it does). When I started FP (in 200*), the first thing I did was to trying to implement the concepts I knew worked and been consistently told to forget everything I knew. It took me a few years to unlearn those concept I was defending before. Apparently, 20 years later people have found clever ways to implement those them, recommending them to beginner deprive them of the chance to unlearn and try to think differently.

I’m not saying that some OOP things can’t be done, only that they shouldn’t be done just for the sake of it.

OOP has some basic principles (as encapsulation) and some tricks/pattern to work around its limitation.
For example encapsulation allows to hide the detail of the code to the caller. It has some benefit, but also some drawback, if things are hidden they are by definition hard to find. This therefore makes harder to debug, because it can be hard to find where is the faulty code. I had an old website written in PHP (drupal) finding anything was an absolute nightmare because the code representing a simple action would be spread into multiple hidden parts. When those parts work, everything is fine. When they don’t finding the culprit was hard. I never have that type of problem with Haskell.

Interface, in OOP was initially invented to circumvent a multiple inheritance issue (the infamous “diamond problem”). As we don’t have inheritence in Haskell, that particular notion of interface doesn’t need to be translated.

Moreover, inheritance and object can be translated differently in Haskell depending on the problems they represents. For example, sum types can the solution to inheritence. Another example is closure or currying.
Old OOP languages couldn’t do closure (or capture a variable and return a function using it). The solution was to create an object with a field(s) containing the captured variable(s). You could have a interface for it. A closure in Haskell is just a function, there is no need to create record for that, there is no need for interfaces either.
The same goes with thunks wich can modeled as objects with an interface.

My points is there is no one to one translation between OOP and FP so saying “here is the way to do X” works only for a limited subset of X.

Me last point about “shoehorn”. It wasn’t meant to be offensive (english is not my first-language), maybe retro-fitted is better. What I mean is that there will always be a qualitative difference between concepts whether they are supported natively by the language or not. If it’s built in the language, then it is usually less verbose and there is usually one prefered way of doing it. When it is not, there is usually some boiler plate and different schools of how to do it (hence all the “effect libraries”).

For example, interface is is built-in in C++ via abstract class (setting a function to = 0). Haskell has built-in support for monads via do syntax. You can do monads in C++ or javascript but you’ll end up with nested functions which are just awkward.

3 Likes

Type classes aren’t interfaces, but you can “do interfaces” in Haskell without any pain. If you don’t need any sort of reflection shenanigans (i.e the interface is genuinely opaque), just reify the interface to a concrete type.

import Control.Monad ((>=>))
import Data.IORef (IORef, modifyIORef, newIORef, readIORef)

-- A very unprincipled typeclass to make this all a bit more ergonomic
class a `Implements` i where
  impl :: a -> i

data RunningSum = RunningSum { sum_ :: IO Integer, add_ :: Integer -> IO () }

newRunningSum :: IO RunningSum
newRunningSum = do
  ref <- newIORef 0
  pure (RunningSum (readIORef ref) (modifyIORef ref . (+)))

data Doubler = Doubler { total_ :: IO Integer, double_ :: IO () }

newDoubler :: IO Doubler
newDoubler = do
  ref <- newIORef 1
  pure (Doubler (readIORef ref) (modifyIORef ref (* 2)))

newtype Incable = Incable { inc_ :: IO () }

instance RunningSum `Implements` Incable where
  impl x = Incable ((add_ x) 1)

instance Doubler `Implements` Incable where
  impl = Incable . double_

stuttered :: Incable -> Incable
stuttered (Incable f) = Incable (f >> f)
-- or, if we wanted to abuse type classes more we could add
-- instance Implements i i where impl = id
-- and then go
-- stuttered :: (a `Implements` Incable) => a -> Incable
-- stuttered x = let f = inc_ (impl x) in Incable (f >> f)
-- but personally, I think this is a mistake.

incBoth :: Incable -> Incable -> Incable
incBoth x y = Incable (inc_ x >> inc_ y)
-- in retrospect, I should have defined this first and gone with
-- stuttered x = incBoth x x

-- Count the number of times we divide by 2 before reaching 1
collatz :: Incable -> Integer -> IO ()
collatz counter = go
  where
    go n
     | n <= 1 = pure ()
     | otherwise = inc_ counter >> go (if even n then n `div` 2 else n * 3 + 1)

main :: IO ()
main = do
  s <- newRunningSum
  d <- newDoubler
  readLn >>= collatz (incBoth (stuttered (impl s)) (impl d))
  (sum_ >=> print) s
  (total_ >=> print) d

That is indeed @graninas’s point, as far as I can tell.

Are there any existing specification/s of “interface-centric” semantics available? (They don’t have to be yours.)

Type classes basically can’t do this. They simply don’t have [what’s required.]

As I vaguely recall, type classes were introduced to allow overloading of identifiers. While there have been attempts to extend them into supporting (variants or features of) object orientation, overloading is still their primary use. And since they’re a language feature, this observation applies:

Furthermore:

…along with the use of extensions like free or existential types.


…hmm:

If I’m understanding that correctly…right now, that could only be achieved by making Haskell’s regular function type (->) more “permissive”, much like that of Standard ML or OCaml: languages where all those type-level bits don’t have to be carried throughout your programs. Those observations by @maxigit apply here too:

  • The purity of Haskell’s function type is at the cost of effects;

  • Allowing direct effects for the corresponding type in SML or OCaml is at the cost of purity.

This is another reason why I’m interested in that specification: it’s then much easier to see objectively what Haskell lacks, instead of trying to rely on subjective personal experience with other languages.


…existentials seem to work quite well here:

And that confinement of mutable state (and the associated side-effects!) to otherwise-regular (pure) Haskell definitions alleviates the need to need to carry those particular type-level bits around! Then there’s the classic:

Perhaps it can help explain why existential types have been mentioned so often in this context.

1 Like

I think @graninas has made it clear that he hasn’t got a fully worked out description of the semantics (which is totally fine; experience and intuition are also important):

1 Like

Duly noted - I was thinking of pre-existing specifications, articles, et al by others: I’ve updated that post.

Hi all, I finally found a convincing argument and a differentiating property.

Firstly, there are two sorts of abstractions: genericity-like abstractions and interface-like ones. The differences between the two are the following (cite from my materials):

  • Interface-like abstractions. Describe the common behavior of similar domain notions and allow the client code to treat each of them uniformly. Implementations can be substituted in runtime transparently for the client code. Does information hiding and encapsulation. Examples: Java and C# interfaces, Haskell’s Free monads, Service Handle pattern, and usual first-class functions.

  • Genericity-like abstractions. Handle the essence of a domain notion with generic type-level declarations. Providing an implementation means specifying the generic type with a specific one at design time and compile time. Examples: generics, type classes, templates in C++, Haskell’s Foldable, Traversable, Monoid, Semigroup, and so on.

So type classes are mostly genericity-like abstractions, but can somewhat mimic interfaces.

The borderline here lies in the information hiding property. Type classes don’t have this property, at least without additional magic (such as existentials).

Here is the modified code of the cooking machine. I added a possibility for the machine to create random recipes for pizza. Notice that it returns another Free monadic language as a result (PizzaRecipe):

data CookingMethod next
 = MakePizza (PizzaRecipe Pizza) (Pizza -> next)
 | MakeSandwich (SandwichRecipe Sandwich) (Sandwich -> next)

 | MakeRandomPizzaRecipe (PizzaRecipe Pizza -> next)

Now, we can ask for a random recipe and run it without leaving the client function:

sampleCookingMachine :: CookingMachine [Meal]
sampleCookingMachine = do
 pizza <- makePizza myPizza

 rndPizzaRecipe <- makeRandomPizzaRecipe    -- asking for rnd recipe 
 rndPizza <- makePizza rndPizzaRecipe       -- evaluating
 pure [pizza, rndPizza]

The type class based solution needs type classes for recipes that are lacking in the previous examples:

class Monad m => CPizzaRecipe m where
  -- TODO

class Monad m => CCookingMachine m where
  cmakePizza :: CPizzaRecipe r => r Pizza -> m Pizza
  cmakeSandwitch :: CSandwichRecipe r => r Sandwich -> m Sandwich

  cmakeRandomPizzaRceipe :: CPizzaRecipe mm => m (mm Pizza)

csampleCookingMachine
  :: forall m mm
  . CPizzaRecipe mm     -- information leaking
  => CCookingMachine m
  => m [Meal]
csampleCookingMachine = do
  -- pizza <- cmakePizza (cMakeCirclePizza ThickCrust [])    -- how to make this compile?
  rndPizzaRecipe :: mm Pizza <- cmakeRandomPizzaRceipe
  rndPizza <- cmakePizza rndPizzaRecipe
  pure [{-PreparedPizza pizza,-} PreparedPizza rndPizza]

Now we have two problems:

  • private information leaking
  • that commented-out routine that doesn’t compile

I believe this demonstrates why type classes are not interfaces although they exhibit some of the needed properties.

There are actually additional differences when it comes to substituting the implementations at runtime, and this is where the differences start being significant. One cannot easily substitute instances of type classes at runtime because it’s a type-level mechanism only.

Full example of code

2 Likes

More on this topic:

Java’s Interface and Haskell’s type class: differences and similarities?

How do type classes differ from interfaces?

What is the difference between Haskell’s type classes and Go’s interfaces?

OOP vs type classes

1 Like

Oh, I have to say the same. I know I sound rude often, and not only because English is not my first language, but because I’m sometimes rude. Sorry for that, and my ad-hominem words were not helping.

3 Likes

I find this topic fascinating, because I feel like this is an instance of the Blub paradox for me. I don’t know if this is related to your viewpoint @graninas, but a while ago I also encountered a difference between type classes and backpack modules. I came up with Semigroup as the example:

class Semigroup a where
   (<>) :: a -> a -> a

Now the thing type classes can do and backpack modules can’t is to instantiate this with another parametrised (what Java calls generic) type:

instance Semigroup [a] where
   (<>) = (++)

So now you have:

(<>) :: [a] -> [a] -> [a]

If we instead had a backpack signature:

signature Semigroup where
  type S
  (<>) :: S -> S -> S

Then we cannot instantiate it like this:

module Semigroup where
  type S = [a]

Because the a would come from nowhere. At best we could use a forall:

  type S = forall a. [a]

But now the type of (<>) doesn’t work: (<>) :: (forall a. [a]) -> (forall a. [a]) -> (forall a. [a]).

I think Java interfaces have the same problem as backpack modules in this respect.


By the way, I think it is a bit easier to read backpack-style interface declaration that free monad ones. Here’s how I would translate your example:

signature Meal where
  type Meal
  -- we don't have subtyping, so we need manual conversions
  -- but I think that is a separate concern
  class IsMeal a where
    toMeal :: a -> Meal

signature PizzaRecipe where
  type PizzaRecipe
  ...

signature CookingMethod where
  import PizzaRecipe

  type CookingMachine a
  instance Functor CookingMachine
  instance Applicative CookingMachine
  instance Monad CookingMachine

  makePizza :: PizzaRecipe -> CookingMachine Pizza
  makeSandwich :: SandwichRecipe -> CookingMachine Sandwich
  makeRandomPizzaRecipe :: CookingMachine PizzaRecipe

And used as:

import Meal
import CookingMethod

sampleCookingMachine :: CookingMachine [Meal]
sampleCookingMachine = do
 pizza <- makePizza myPizza

 rndPizzaRecipe <- makeRandomPizzaRecipe    -- asking for rnd recipe 
 rndPizza <- makePizza rndPizzaRecipe       -- evaluating
 pure [toMeal pizza, toMeal rndPizza]
2 Likes

@jaror Looks very interesting!

Yes, module systems are an interface-like abstractions especially if they have some additional properties, for example, first-classness

I wonder, would you consider this a ‘mimic’ of interfaces, or does it get closer to the real thing for you? Or is this ‘additional magic’ (the chooseMildlyRegressiveEthnicStereotype function does basically encode an existential type)?

class Monad m => SandwichRecipe impl m where
  startNewSandwich :: impl -> BreadType -> Component -> m SandwichBody
  addComponent :: impl -> Component -> SandwichBody -> m SandwichBody
  finishSandwich :: impl -> Maybe BreadType -> SandwichBody -> m Sandwich

class Monad m => PizzaRecipe impl m where
  makeCirclePizza :: impl -> Crust -> [PizzaComponent] -> m Pizza
  makeSquarePizza :: impl -> Crust -> [PizzaComponent] -> m Pizza

class Monad m => CookingMachine impl m where
  makePizza :: impl -> Pizza -> m Meal
  makeSandwich :: impl -> Sandwich -> m Meal
  makeRandomPizzaRecipe :: impl -> m Pizza

mySandwich :: SandwichRecipe impl m => impl -> m Sandwich
mySandwich impl = do
  body1 <- startNewSandwich impl Toast Tomato
  body2 <- addComponent impl Cheese body1
  body3 <- addComponent impl Salt body2
  finishSandwich impl Nothing body3

sampleCookingMachine :: CookingMachine cook m => PizzaRecipe pizza m => cook -> pizza -> m [Meal]
sampleCookingMachine cook pizza = do
  -- note the separation of `cook` and `pizza` allows different implementations to be provided, as long as they run in the same monad
  pizza <- makePizza cook =<< myPizza pizza

  rndPizzaRecipe <- makeRandomPizzaRecipe cook
  rndPizza <- makePizza cook rndPizzaRecipe
  pure [pizza, rndPizza]

data ItalianChef = ItalianChef
data SwedishChef = SwedishChef

instance PizzaRecipe ItalianChef IO where
  -- TODO: That's a nice-a pizza!
instance PizzaRecipe SwedishChef IO where
  -- TODO: Bork bork bork!
instance SandwichRecipe ItalianChef IO where
  -- TODO
instance SandwichRecipe SwedishChef IO where
  -- TODO
instance CookingMachine ItalianChef IO where
  -- TODO
instance CookingMachine SwedishChef IO where
  -- TODO

chooseMildlyRegressiveEthnicStereotype ::
  ( forall impl.
    PizzaRecipe impl IO =>
    SandwichRecipe impl IO =>
    CookingMachine impl IO =>
      impl -> IO a
  ) ->
    IO a
chooseMildlyRegressiveEthnicStereotype f = do
  -- lookup implementation at runtime
  condition <- lookupConfig
  if condition then f ItalianChef else f SwedishChef

main :: IO ()
main =
  chooseMildlyRegressiveEthnicStereotype $ \chef -> do
    meals <- sampleCookingMachine chef chef
    print meals
1 Like

Looks like mimicing to me!

But yes, this idea comes first in mind.

It doesn’t feel okay to me though, because having something related to implementations in the interface is quite contrary to why we need interfaces. In this case, it’s indirectly about implementations but still.

Not sure if it’s existential of any kind, I’m not an expert here.

I foresee some other (negative) consequences of this design to other parts, but this feel needs a careful formulation.

P.S. I still need to read all other messages here. Sorry, I’m kinda lazy