RFC: Derive Struct
and Typed
instances for structs using GHC.Generics
#540
RyanGlScott
started this conversation in
General
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
-
One of the more tedious aspects of using structs in Copilot is the need to manually define
Struct
andTyped
instances for each Haskell data type that represents a struct. Each of the instances requires an amount of boilerplate code that is grows linearly with the number of fields in the data type. (For an example of this, see this struct-related example in thecopilot
repo.)I think the experience of using structs would be greatly improved if GHC could automate as much of the process of defining these instances as possible. Luckily, such a thing is possible using
GHC.Generics
, and I propose that we make it possible to leverageGHC.Generics
to deriveStruct
andTyped
instances. (This proposal was inspired the similar work in #516, although this proposal differs from that work slightly.)Recap: the
Struct
andTyped
classesTo illustrate what this proposal will do, let's use the
Volts
data type from thecopilot
repo as a running example:copilot/copilot/examples/Structs.hs
Lines 14 to 18 in 068c06d
This is a simple struct-related data type with two fields,
nunVolts
andflag
. In order to profitably useVolts
, it requires an instance of theStruct
class, which looks like this:copilot/copilot/examples/Structs.hs
Lines 20 to 25 in 068c06d
Struct
has three methods:typeName
: The name of the struct to use in the Copilot-generated code (usually C).toValues
: Converts all of theField
s toValue
s.updateField
: Dispatches on a particularField
and updates theValue
contained inside. (Note thatupdateField
isn't explicitly implemented in the example above, but you can see an example of how one would do it here.)The
toValues
andupdateField
methods are extremely mechanical to implement, as their implementations are determined entirely by the type and names of eachField
. These are prime candidates for automating viaGHC.Generics
.The
typeName
method is less mechanical, as it requires a choice on the behalf of the programmer to determine how the struct will be named in the Copilot-generated code. In the example above, the programmer chooses to use the all-lowercase namevolts
instead of the CamelCase nameVolts
. As such, it's unclear if it is possible (or even desirable) to automate the generation of thetypeName
method. (We'll return to this later.)Volts
also requires aTyped
instance, which looks like this:copilot/copilot/examples/Structs.hs
Lines 27 to 29 in 068c06d
Using
GHC.Generics
I propose to design things in such a way that
Struct
andTyped
are (mostly) automated usingGHC.Generics
. There are a number of ways that we could go about this, but regardless of which way we pick, we will need to giveVolts
an instance of theGeneric
class. This can be done using theDeriveGeneric
language extension:Option 1: Explicitly using generic defaults
This option is the least invasive, as it would not require changing anything about the current definitions of the
Struct
orTyped
classes. The idea is that incopilot-core
, we would define "default" versions oftoValues
,updateField
, andtypeOf
with roughly these type signatures:And then when implementing
Struct
andTyped
instances for a data type with aGeneric
instance, one can simply define them like so:There is still some boilerplate required in defining the instance, but only a constant amount. (Note that we still require the user to implement
typeName
manually.)Option 2a: Implicitly using generic defaults
We can reduce the amount of boilerplate required if we are willing to change the definitions of the
Struct
andTyped
classes a bit. Currently, they're defined as:copilot/copilot-core/src/Copilot/Core/Type.hs
Lines 54 to 64 in 068c06d
And:
copilot/copilot-core/src/Copilot/Core/Type.hs
Lines 192 to 197 in 068c06d
Note that some methods (
updateField
andsimpleType
) already have default implementations if you define instances without giving them explicit implementations. As an alternative to option (1), we can change the default implementations so that they use theGeneric
-based defaults instead. That is:With these defaults, you can now define
Struct
andTyped
instances like so:Note that we have changed the default implementation for
updateField
to require aGeneric
constraint, so it is no longer possible to omit anupdateField
implementation unless the data type has aGeneric
instance. (More on this later.)Option 2b: Using
deriving
-related GHC extensionsAdvanced Haskellers will recognize that we don't need to write out the
Struct
andTyped
instances in a standalone fashion. Instead, we can derive them just as we derive theGeneric
instance. To do so, we will need to make use of some additional GHC language extensions that augment thederiving
keyword with extra powers:Now there are no
instance
declarations anymore: justderiving
!How does this all work? Let's go through this bit by bit:
The
DerivingStrategies
language extension allows specifying strategies to use with each use of thederiving
keyword. For instance, thestock
strategy is the usual strategy that derivable classes mentioned in the Haskell Report use.(Note that we could just as well leave off the
stock
strategy and writederiving Generic
instead ofderiving stock Generic
—they're equivalent ways of writing the same thing. I need to use other deriving strategies elsewhere in this program, however, so I decided to be explicit here about which deriving strategy is in use.)The
DerivingAnyClass
andDerivingVia
language extensions allow using theanyclass
andvia
deriving strategies.Writing
deriving anyclass Typed
derives aTyped
instance as though you had written a separateinstance Typed Volts
declaration (using default implementations for all methods). Again, we could technically leave off theanyclass
strategy here, but I decided to be explicit.deriving Struct via (GenericStruct "volts" Volts)
is the most interesting part. In order for this to work,copilot
needs to offer aGenericStruct
newtype:This newtype should also come equipped with a
Struct
instance that leveragesGeneric
-based defaults:Now, one can use
DerivingVia
to derive aStruct
instance that reuses the existingStruct
instance forGenericStruct
. This works becauseVolts
andGenericStruct s Volts
have the same underlying representation. Thes
inGenericStruct s
specifies howtypeName
should be implemented, and this is the only place where the programmer has to make a choice.Note that this approach (option 2b) is fully compatible with option 2a above, as both options are different syntaxes for accomplishing the same thing. As such, advanced Haskellers can use this approach if they want, but if the use of
deriving
-related GHC extensions is too much, one can always fall back to the (comparatively less advanced) approach used in option 2a. I'll collectively refer to both option 2a and 2b as "option 2".Option 1 or 2?
As noted above, option 1 does not require any changes to the defaults in the
Struct
andTyped
classes, while option 2 does. As such, option 2 requires a backwards-compatible API change, as there would be existingStruct
/Typed
instances that would no longer compile unless the user added aGeneric
instance to their struct types.Personally, I am in favor of option 2, even with the need for an API change. Given how simple it is to derive a
Generic
instance, migrating existing code to the new defaults should be very straightforward. As such, I think this is an acceptable price to pay.Beta Was this translation helpful? Give feedback.
All reactions