Representation of single-method classes
Dear GHC friends, I would like to know whether the representation of FooD1: data Foo = MkFoo A B class FooD1 where foo :: Foo has a representation at least as efficient as FooD2: class FooD2 where fooA :: A fooB :: B in particular, that FooD1 doesn't occur an extra indirection. Now, according to https://gitlab.haskell.org/ghc/ghc/-/issues/20897 FooD1 used to be compiled as a newtype, so the answer was "yes". Then something called "Plan B" (described in that ticket) was enacted, and I believe the answer is therefore still "yes". Can someone please confirm that? For a teaser of why I want this: I find type classes very difficult to evolve in a way that satisfies my stability needs. Part of the reason for this is that type classes as typically used don't really permit any form of data abstraction: you list out all the methods explicitly in the class definition. There is no data hiding. To address this issue I am considering defining classes abstractly, like class MyClass where myClassImpl :: MyClassD for some abstract type MyClassD (i.e. whose implementation details are not exposed to users) and I want to know if there would be a performance cost. Thanks, Tom
Classes with exactly one method and no superclass (or one superclass and no method) are called "unary classes". And yes, they are still implemented with no overhead. See this long Note: https://gitlab.haskell.org/ghc/ghc/-/blame/master/compiler/GHC/Core/TyCon.hs... I find type classes very difficult to
evolve in a way that satisfies my stability needs. Part of the reason for this is that type classes as typically used don't really permit any form of data abstraction: you list out all the methods explicitly in the class definition. There is no data hiding.
That's odd. Can't you say ``` module M( C, warble ) where class C a where { op1, op2 :: a -> a } warble :: C a => a -> a warble = ... ``` and now a client of `M` can see `C` and `warble` but has no idea of the methods. Of course if a client wants to make a new data type T into an instance of C then they need to know the methods, but that's reasonable: to make T an instance of C we must provide a witness for `op1` and `op2`. So your teaser is indeed teasing. Simon On Wed, 10 Dec 2025 at 20:59, Tom Ellis < tom-lists-haskell-cafe-2023@jaguarpaw.co.uk> wrote:
Dear GHC friends,
I would like to know whether the representation of FooD1:
data Foo = MkFoo A B
class FooD1 where foo :: Foo
has a representation at least as efficient as FooD2:
class FooD2 where fooA :: A fooB :: B
in particular, that FooD1 doesn't occur an extra indirection. Now, according to
https://gitlab.haskell.org/ghc/ghc/-/issues/20897
FooD1 used to be compiled as a newtype, so the answer was "yes". Then something called "Plan B" (described in that ticket) was enacted, and I believe the answer is therefore still "yes".
Can someone please confirm that?
For a teaser of why I want this: I find type classes very difficult to evolve in a way that satisfies my stability needs. Part of the reason for this is that type classes as typically used don't really permit any form of data abstraction: you list out all the methods explicitly in the class definition. There is no data hiding. To address this issue I am considering defining classes abstractly, like
class MyClass where myClassImpl :: MyClassD
for some abstract type MyClassD (i.e. whose implementation details are not exposed to users) and I want to know if there would be a performance cost.
Thanks,
Tom _______________________________________________ ghc-devs mailing list -- ghc-devs@haskell.org To unsubscribe send an email to ghc-devs-leave@haskell.org
On Thu, Dec 11, 2025 at 09:15:16AM +0000, Simon Peyton Jones wrote:
Classes with exactly one method and no superclass (or one superclass and no method) are called "unary classes". And yes, they are still implemented with no overhead.
See this long Note: https://gitlab.haskell.org/ghc/ghc/-/blame/master/compiler/GHC/Core/TyCon.hs...
Super, thank you for the reference.
I find type classes very difficult to evolve in a way that satisfies my stability needs. Part of the reason for this is that type classes as typically used don't really permit any form of data abstraction: you list out all the methods explicitly in the class definition. There is no data hiding.
That's odd. Can't you say ``` module M( C, warble ) where class C a where { op1, op2 :: a -> a }
warble :: C a => a -> a warble = ... ``` and now a client of `M` can see `C` and `warble` but has no idea of the methods.
That deals with one direction across the abstraction boundary: the elimination form. We also need introduction forms as you point out:
Of course if a client wants to make a new data type T into an instance of C then they need to know the methods, but that's reasonable: to make T an instance of C we must provide a witness for `op1` and `op2`. So your teaser is indeed teasing.
Right, and once witnesses have been provided for `op1` and `op2`, the client is now coupled to that interface. Here's what I'm suggesting instead: -- | Crucially, CD is abstract module M( C, CD, op1, op2, warble, Ops(..), cdOfOps ) where data CD a = MkCD { op1Impl :: a -> a, op2Impl :: a -> a } class C a where cImpl :: CD a warble :: C a => a -> a warble = ... op1 :: C a => a -> a op1 = op1Impl cImpl op2 :: C a => a -> a op2 = op2Impl cImpl data Ops a = MkOps { opsOp1 :: a -> a, opsOp2 :: a -> a } cdOfOps :: Ops a -> CD a cdOfOps ops = MkCD { op1Impl = opsOp1 ops, op2Impl = opsOp2 ops } And clients can now define instance C T where cImpl = cdOfOps MkOps { opsOp1 = ..., opsOp2 = ... } But I can also provide more helper functions such as these: cdOfId :: CD a cdOfId = MkCD {op1Impl = id, op2Impl = id} cdOfTwice :: (a -> a) -> CD a cdOfTwice f = MkCD {op1Impl = f, op2Impl = f . f} So instances can be written briefly, in a way that is typically done with DerivingVia: instance C T2 where cImpl = cdOfId instance C Bool where cImpl = cdOfTwice not Why do this? Suppose I realise that it is a law that `op2` must *always* be `op1 . op1`. Then `cdOfOps` becomes risky, and I can add a warning to it, deprecate it, and subsequently remove it if I want. Everything else, including `cdOfId` and `cdOfTwice` are safe, and can remain unchanged. There is no easy path if `op2` is a method. I can't add a warning to it, because it's still safe to *use* it and client code will be using it. It's just unsafe to *define* it. Ideally it should be lifted out of the class definition and defined as `op2 = op1 . op1`, but that breaks every client who has a C instance defined, without the ability to provide a smooth deprecation cycle. Anyway, I hope to be able to write this up in more detail in the near future, including the benefits I see we would have had during AMP, Monad Of No Return, and the proposal to remove (>>) from Monad, if this approach had been standard practice. Tom
Doesn't this undermine a lot of the specialization-based optimizations to get rid of runtime dictionary passing? On Thu, Dec 11, 2025, 21:01 Tom Ellis < tom-lists-haskell-cafe-2023@jaguarpaw.co.uk> wrote:
On Thu, Dec 11, 2025 at 09:15:16AM +0000, Simon Peyton Jones wrote:
Classes with exactly one method and no superclass (or one superclass and no method) are called "unary classes". And yes, they are still implemented with no overhead.
See this long Note:
https://gitlab.haskell.org/ghc/ghc/-/blame/master/compiler/GHC/Core/TyCon.hs...
Super, thank you for the reference.
I find type classes very difficult to evolve in a way that satisfies my stability needs. Part of the reason for this is that type classes as typically used don't really permit any form of data abstraction: you list out all the methods explicitly in the class definition. There is no data hiding.
That's odd. Can't you say ``` module M( C, warble ) where class C a where { op1, op2 :: a -> a }
warble :: C a => a -> a warble = ... ``` and now a client of `M` can see `C` and `warble` but has no idea of the methods.
That deals with one direction across the abstraction boundary: the elimination form. We also need introduction forms as you point out:
Of course if a client wants to make a new data type T into an instance of C then they need to know the methods, but that's reasonable: to make T an instance of C we must provide a witness for `op1` and `op2`. So your teaser is indeed teasing.
Right, and once witnesses have been provided for `op1` and `op2`, the client is now coupled to that interface. Here's what I'm suggesting instead:
-- | Crucially, CD is abstract module M( C, CD, op1, op2, warble, Ops(..), cdOfOps ) where
data CD a = MkCD { op1Impl :: a -> a, op2Impl :: a -> a }
class C a where cImpl :: CD a
warble :: C a => a -> a warble = ...
op1 :: C a => a -> a op1 = op1Impl cImpl
op2 :: C a => a -> a op2 = op2Impl cImpl
data Ops a = MkOps { opsOp1 :: a -> a, opsOp2 :: a -> a }
cdOfOps :: Ops a -> CD a cdOfOps ops = MkCD { op1Impl = opsOp1 ops, op2Impl = opsOp2 ops }
And clients can now define
instance C T where cImpl = cdOfOps MkOps { opsOp1 = ..., opsOp2 = ... }
But I can also provide more helper functions such as these:
cdOfId :: CD a cdOfId = MkCD {op1Impl = id, op2Impl = id}
cdOfTwice :: (a -> a) -> CD a cdOfTwice f = MkCD {op1Impl = f, op2Impl = f . f}
So instances can be written briefly, in a way that is typically done with DerivingVia:
instance C T2 where cImpl = cdOfId
instance C Bool where cImpl = cdOfTwice not
Why do this? Suppose I realise that it is a law that `op2` must *always* be `op1 . op1`. Then `cdOfOps` becomes risky, and I can add a warning to it, deprecate it, and subsequently remove it if I want. Everything else, including `cdOfId` and `cdOfTwice` are safe, and can remain unchanged.
There is no easy path if `op2` is a method. I can't add a warning to it, because it's still safe to *use* it and client code will be using it. It's just unsafe to *define* it. Ideally it should be lifted out of the class definition and defined as `op2 = op1 . op1`, but that breaks every client who has a C instance defined, without the ability to provide a smooth deprecation cycle.
Anyway, I hope to be able to write this up in more detail in the near future, including the benefits I see we would have had during AMP, Monad Of No Return, and the proposal to remove (>>) from Monad, if this approach had been standard practice.
Tom _______________________________________________ ghc-devs mailing list -- ghc-devs@haskell.org To unsubscribe send an email to ghc-devs-leave@haskell.org
On Thu, Dec 11, 2025 at 09:07:28PM +0800, Gergő Érdi wrote:
Doesn't this undermine a lot of the specialization-based optimizations to get rid of runtime dictionary passing?
If it does then my idea is dead on arrival for anything performance sensitive. But why would it? I asked the question because I was concerned that classes with a single method containing a dictionary might have performance overhead compared to "unpacking the dictionary" in the class body, i.e. the difference between Simon's class C a where { op1, op2 :: a -> a } and my data CD a = MkCD { op1Impl :: a -> a, op2Impl :: a -> a } class C a where cImpl :: CD a Simon says there is no overhead. What specialization-based optimizations are you thinking of that may not apply to my version? Tom
On Thu, Dec 11, 2025, 21:01 Tom Ellis < tom-lists-haskell-cafe-2023@jaguarpaw.co.uk> wrote:
On Thu, Dec 11, 2025 at 09:15:16AM +0000, Simon Peyton Jones wrote:
Classes with exactly one method and no superclass (or one superclass and no method) are called "unary classes". And yes, they are still implemented with no overhead.
See this long Note:
https://gitlab.haskell.org/ghc/ghc/-/blame/master/compiler/GHC/Core/TyCon.hs...
Super, thank you for the reference.
I find type classes very difficult to evolve in a way that satisfies my stability needs. Part of the reason for this is that type classes as typically used don't really permit any form of data abstraction: you list out all the methods explicitly in the class definition. There is no data hiding.
That's odd. Can't you say ``` module M( C, warble ) where class C a where { op1, op2 :: a -> a }
warble :: C a => a -> a warble = ... ``` and now a client of `M` can see `C` and `warble` but has no idea of the methods.
That deals with one direction across the abstraction boundary: the elimination form. We also need introduction forms as you point out:
Of course if a client wants to make a new data type T into an instance of C then they need to know the methods, but that's reasonable: to make T an instance of C we must provide a witness for `op1` and `op2`. So your teaser is indeed teasing.
Right, and once witnesses have been provided for `op1` and `op2`, the client is now coupled to that interface. Here's what I'm suggesting instead:
-- | Crucially, CD is abstract module M( C, CD, op1, op2, warble, Ops(..), cdOfOps ) where
data CD a = MkCD { op1Impl :: a -> a, op2Impl :: a -> a }
class C a where cImpl :: CD a
warble :: C a => a -> a warble = ...
op1 :: C a => a -> a op1 = op1Impl cImpl
op2 :: C a => a -> a op2 = op2Impl cImpl
data Ops a = MkOps { opsOp1 :: a -> a, opsOp2 :: a -> a }
cdOfOps :: Ops a -> CD a cdOfOps ops = MkCD { op1Impl = opsOp1 ops, op2Impl = opsOp2 ops }
And clients can now define
instance C T where cImpl = cdOfOps MkOps { opsOp1 = ..., opsOp2 = ... }
But I can also provide more helper functions such as these:
cdOfId :: CD a cdOfId = MkCD {op1Impl = id, op2Impl = id}
cdOfTwice :: (a -> a) -> CD a cdOfTwice f = MkCD {op1Impl = f, op2Impl = f . f}
So instances can be written briefly, in a way that is typically done with DerivingVia:
instance C T2 where cImpl = cdOfId
instance C Bool where cImpl = cdOfTwice not
Why do this? Suppose I realise that it is a law that `op2` must *always* be `op1 . op1`. Then `cdOfOps` becomes risky, and I can add a warning to it, deprecate it, and subsequently remove it if I want. Everything else, including `cdOfId` and `cdOfTwice` are safe, and can remain unchanged.
There is no easy path if `op2` is a method. I can't add a warning to it, because it's still safe to *use* it and client code will be using it. It's just unsafe to *define* it. Ideally it should be lifted out of the class definition and defined as `op2 = op1 . op1`, but that breaks every client who has a C instance defined, without the ability to provide a smooth deprecation cycle.
Anyway, I hope to be able to write this up in more detail in the near future, including the benefits I see we would have had during AMP, Monad Of No Return, and the proposal to remove (>>) from Monad, if this approach had been standard practice.
My question is, if you do this thoroughly and end up with plain old functions getting plain old record parameters containing the methods, is GHC capable of seeing through enough calls to figure out that all these function arguments are actually fully determined by typeclass instances, and thus by types, and thus they can be eliminated based on the types alone? Maybe it is, this is a genuine question, although I can see how my original reply reads like an implicit "Surely," 😀 On Thu, Dec 11, 2025, 21:14 Tom Ellis < tom-lists-haskell-cafe-2023@jaguarpaw.co.uk> wrote:
On Thu, Dec 11, 2025 at 09:07:28PM +0800, Gergő Érdi wrote:
Doesn't this undermine a lot of the specialization-based optimizations to get rid of runtime dictionary passing?
If it does then my idea is dead on arrival for anything performance sensitive. But why would it? I asked the question because I was concerned that classes with a single method containing a dictionary might have performance overhead compared to "unpacking the dictionary" in the class body, i.e. the difference between Simon's
class C a where { op1, op2 :: a -> a }
and my
data CD a = MkCD { op1Impl :: a -> a, op2Impl :: a -> a } class C a where cImpl :: CD a
Simon says there is no overhead. What specialization-based optimizations are you thinking of that may not apply to my version?
Tom
On Thu, Dec 11, 2025, 21:01 Tom Ellis < tom-lists-haskell-cafe-2023@jaguarpaw.co.uk> wrote:
On Thu, Dec 11, 2025 at 09:15:16AM +0000, Simon Peyton Jones wrote:
Classes with exactly one method and no superclass (or one superclass and no method) are called "unary classes". And yes, they are still implemented with no overhead.
See this long Note:
https://gitlab.haskell.org/ghc/ghc/-/blame/master/compiler/GHC/Core/TyCon.hs...
Super, thank you for the reference.
I find type classes very difficult to evolve in a way that satisfies my stability needs. Part of the reason for this is that type classes as typically used don't really permit any form of data abstraction: you list out all the methods explicitly in the class definition. There is no data hiding.
That's odd. Can't you say ``` module M( C, warble ) where class C a where { op1, op2 :: a -> a }
warble :: C a => a -> a warble = ... ``` and now a client of `M` can see `C` and `warble` but has no idea of
the
methods.
That deals with one direction across the abstraction boundary: the elimination form. We also need introduction forms as you point out:
Of course if a client wants to make a new data type T into an instance of C then they need to know the methods, but that's reasonable: to make T an instance of C we must provide a witness for `op1` and `op2`. So your teaser is indeed teasing.
Right, and once witnesses have been provided for `op1` and `op2`, the client is now coupled to that interface. Here's what I'm suggesting instead:
-- | Crucially, CD is abstract module M( C, CD, op1, op2, warble, Ops(..), cdOfOps ) where
data CD a = MkCD { op1Impl :: a -> a, op2Impl :: a -> a }
class C a where cImpl :: CD a
warble :: C a => a -> a warble = ...
op1 :: C a => a -> a op1 = op1Impl cImpl
op2 :: C a => a -> a op2 = op2Impl cImpl
data Ops a = MkOps { opsOp1 :: a -> a, opsOp2 :: a -> a }
cdOfOps :: Ops a -> CD a cdOfOps ops = MkCD { op1Impl = opsOp1 ops, op2Impl = opsOp2 ops }
And clients can now define
instance C T where cImpl = cdOfOps MkOps { opsOp1 = ..., opsOp2 = ... }
But I can also provide more helper functions such as these:
cdOfId :: CD a cdOfId = MkCD {op1Impl = id, op2Impl = id}
cdOfTwice :: (a -> a) -> CD a cdOfTwice f = MkCD {op1Impl = f, op2Impl = f . f}
So instances can be written briefly, in a way that is typically done with DerivingVia:
instance C T2 where cImpl = cdOfId
instance C Bool where cImpl = cdOfTwice not
Why do this? Suppose I realise that it is a law that `op2` must *always* be `op1 . op1`. Then `cdOfOps` becomes risky, and I can add a warning to it, deprecate it, and subsequently remove it if I want. Everything else, including `cdOfId` and `cdOfTwice` are safe, and can remain unchanged.
There is no easy path if `op2` is a method. I can't add a warning to it, because it's still safe to *use* it and client code will be using it. It's just unsafe to *define* it. Ideally it should be lifted out of the class definition and defined as `op2 = op1 . op1`, but that breaks every client who has a C instance defined, without the ability to provide a smooth deprecation cycle.
Anyway, I hope to be able to write this up in more detail in the near future, including the benefits I see we would have had during AMP, Monad Of No Return, and the proposal to remove (>>) from Monad, if this approach had been standard practice.
ghc-devs mailing list -- ghc-devs@haskell.org To unsubscribe send an email to ghc-devs-leave@haskell.org
It's certainly a question that it's important to know the answer to, for my purposes! Given that one can *already* define a type class method whose value is a product type containing one or more function types then I hope GHC's optimizer is already capable of doing the necessary work there! Tom On Thu, Dec 11, 2025 at 09:19:37PM +0800, Gergő Érdi wrote:
My question is, if you do this thoroughly and end up with plain old functions getting plain old record parameters containing the methods, is GHC capable of seeing through enough calls to figure out that all these function arguments are actually fully determined by typeclass instances, and thus by types, and thus they can be eliminated based on the types alone?
Maybe it is, this is a genuine question, although I can see how my original reply reads like an implicit "Surely," 😀
On Thu, Dec 11, 2025, 21:14 Tom Ellis < tom-lists-haskell-cafe-2023@jaguarpaw.co.uk> wrote:
On Thu, Dec 11, 2025 at 09:07:28PM +0800, Gergő Érdi wrote:
Doesn't this undermine a lot of the specialization-based optimizations to get rid of runtime dictionary passing?
If it does then my idea is dead on arrival for anything performance sensitive. But why would it? I asked the question because I was concerned that classes with a single method containing a dictionary might have performance overhead compared to "unpacking the dictionary" in the class body, i.e. the difference between Simon's
class C a where { op1, op2 :: a -> a }
and my
data CD a = MkCD { op1Impl :: a -> a, op2Impl :: a -> a } class C a where cImpl :: CD a
Simon says there is no overhead. What specialization-based optimizations are you thinking of that may not apply to my version?
Tom
On Thu, Dec 11, 2025, 21:01 Tom Ellis < tom-lists-haskell-cafe-2023@jaguarpaw.co.uk> wrote:
On Thu, Dec 11, 2025 at 09:15:16AM +0000, Simon Peyton Jones wrote:
Classes with exactly one method and no superclass (or one superclass and no method) are called "unary classes". And yes, they are still implemented with no overhead.
See this long Note:
https://gitlab.haskell.org/ghc/ghc/-/blame/master/compiler/GHC/Core/TyCon.hs...
Super, thank you for the reference.
I find type classes very difficult to evolve in a way that satisfies my stability needs. Part of the reason for this is that type classes as typically used don't really permit any form of data abstraction: you list out all the methods explicitly in the class definition. There is no data hiding.
That's odd. Can't you say ``` module M( C, warble ) where class C a where { op1, op2 :: a -> a }
warble :: C a => a -> a warble = ... ``` and now a client of `M` can see `C` and `warble` but has no idea of
the
methods.
That deals with one direction across the abstraction boundary: the elimination form. We also need introduction forms as you point out:
Of course if a client wants to make a new data type T into an instance of C then they need to know the methods, but that's reasonable: to make T an instance of C we must provide a witness for `op1` and `op2`. So your teaser is indeed teasing.
Right, and once witnesses have been provided for `op1` and `op2`, the client is now coupled to that interface. Here's what I'm suggesting instead:
-- | Crucially, CD is abstract module M( C, CD, op1, op2, warble, Ops(..), cdOfOps ) where
data CD a = MkCD { op1Impl :: a -> a, op2Impl :: a -> a }
class C a where cImpl :: CD a
warble :: C a => a -> a warble = ...
op1 :: C a => a -> a op1 = op1Impl cImpl
op2 :: C a => a -> a op2 = op2Impl cImpl
data Ops a = MkOps { opsOp1 :: a -> a, opsOp2 :: a -> a }
cdOfOps :: Ops a -> CD a cdOfOps ops = MkCD { op1Impl = opsOp1 ops, op2Impl = opsOp2 ops }
And clients can now define
instance C T where cImpl = cdOfOps MkOps { opsOp1 = ..., opsOp2 = ... }
But I can also provide more helper functions such as these:
cdOfId :: CD a cdOfId = MkCD {op1Impl = id, op2Impl = id}
cdOfTwice :: (a -> a) -> CD a cdOfTwice f = MkCD {op1Impl = f, op2Impl = f . f}
So instances can be written briefly, in a way that is typically done with DerivingVia:
instance C T2 where cImpl = cdOfId
instance C Bool where cImpl = cdOfTwice not
Why do this? Suppose I realise that it is a law that `op2` must *always* be `op1 . op1`. Then `cdOfOps` becomes risky, and I can add a warning to it, deprecate it, and subsequently remove it if I want. Everything else, including `cdOfId` and `cdOfTwice` are safe, and can remain unchanged.
There is no easy path if `op2` is a method. I can't add a warning to it, because it's still safe to *use* it and client code will be using it. It's just unsafe to *define* it. Ideally it should be lifted out of the class definition and defined as `op2 = op1 . op1`, but that breaks every client who has a C instance defined, without the ability to provide a smooth deprecation cycle.
Anyway, I hope to be able to write this up in more detail in the near future, including the benefits I see we would have had during AMP, Monad Of No Return, and the proposal to remove (>>) from Monad, if this approach had been standard practice.
ghc-devs mailing list -- ghc-devs@haskell.org To unsubscribe send an email to ghc-devs-leave@haskell.org
_______________________________________________ ghc-devs mailing list -- ghc-devs@haskell.org To unsubscribe send an email to ghc-devs-leave@haskell.org
participants (3)
-
Gergő Érdi -
Simon Peyton Jones -
Tom Ellis