Rust-style Attribute Macro in Haskell, using GHC 10 Modifiers Extension

As a long-time Haskeller writing Rust in production, I always miss the powerful Template Haskell macros - Rust’s macro system is rather ad-hoc and weaker than Haskell (it fails to handle hygienity properly, and, crucially, lacks reification mechanism at all!).
But there has always been one thing that I miss one Rust macro mechanism in Haskell: attribute macros.
They are so powerful when combined with their (macro-based) deriving mechanism, as one can use it to control the deriving strategy in the granularity of type/data constructor or fields.
For example, the following derives Deserialize instance from serde (something like aeson or autodocodec in Rust):

#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Outer {
    id: String,
    #[serde(rename = "maxValue"))]
    max_value: u64,
    #[serde(default = "LogLevel::Info")]
    log_level: LogLevel
}

Recently, I heard about Modifiers extension will be shipped with GHC 10, and I realise that we are can achieve the closer thing with it!
…And, here it is:

In short, Modifiers extension allows several places of source code to be attributed with types, and it is a generalisation of a %1 -> b syntax introduced in LinearTypes to allow arbitrary kinds to appear inside %(..).
In this example, Types appearing in field attribute to be used as an implementation selector when deriving instances.
(One caveat: for simplicity, we are just using plain type to annotate it representation - but it can easily collide with other library / compiler features using modifiers, such as LinearTypes. So, in production, it should be necessary to wrap modifiers around custom type constructor to avoid confusion,)

This proof of concept derives Monoid instances based on (optional) modifiers on fields. More precisely, it is a Source Plugin that collects the information of fields with modifiers and saves it as a (private) type family instance.
Then ModifiersOn implements the default implementation based on that type family, and switches the Monoid implementations of fields to use annotated types given by modifiers (via coerce function).

So, for example, the above code:

{-# OPTIONS_GHC -fplugin Data Monoid.Deriving Modifiers #-}
{-# OPTIONS_GHC -Wno-unrecognised-modifiers #-}
module _ where 

-- ...

data Foo = Foo
  { total           %(Sum Int) :: !Int
  , power       %(Product Int) :: !Int
  , backwards %(Dual [String]) :: [String]
  , forwards                   :: [Int]
  }
  deriving (Show, Eq, Ord, Generic)
  deriving (Semigroup, Monoid) via ModifiersOn (Foo a)

…has representationally the same Monoid instance as follows:

data FooM = FooM
  { total     :: !(Sum Int)
  , power     :: !(Product Int)
  , backwards :: Dual [String]
  , forwards  :: [Int]
  }
  deriving (Show, Eq, Ord, Generic)
  deriving (Semigroup, Monoid) via Generically (FooM a)

… but each field of Foo is not wrapped by newtypes and users can directly access them without ripping them off!
In this way, we can specify the “semantics for instance derivation” and “field value representation” separately. This is in a same spirits of SameRepAs newtype wrapper proposed in DerivingVia paper, but annotated more locally and hence much maintainable compared to manually mapping to the type with the sum-of-product representation.

Although this example is something like a DerivingVia in disguise, but I think this mechanism can further be extended beyond that - for example, as in serde example, I think we can specify field-level encoding strategy for aeson by type-level programming machinery - I think this can also replace some use-case of HKDs.

Although it currently collects modifiers only in field position, but I think it is straightforward to extend it to collect modifiers on data declaration or data constructors.
(One alternative is {-# ANN #-} pragma, but it is more restrictive on where it can be placed and annotation has to be an expression rather than types.)

As this direction seems really promising, it would be more convenient to have builtin mechanism to collect the modifier information through type-level machinery or Template Haskell macro, right builtin to GHC. Otherwise, we had to hand-roll compiler plugin to collect modifier information just as in this example. Still, Modifiers is really a neat and inspiring language feature as it is!

Happy Modified Haskelling!

30 Likes

Which is something you could already do by writing an instance manually. It would be exactly as (or less) verbose, strictly more powerful, and require no extra dependencies.

I would caveat about bidirectional serialization being error-prone, except in my experience the vast majority of serialization is unidirectional.

3 Likes

Which is something you could already do by writing an instance manually. It would be exactly as (or less) verbose, strictly more powerful, and require with no extra dependencies.

I would caveat about bidirectional serialization being error-prone, except in my experience the vast majority of serialization is unidirectional.

Indeed! So you no longer need Generic deriving, DerivingVia, Template Haskell macro, GADTs, TypeFamilies, or anything fancier mumbo-jumbos which makes GHC GHC - or even stock derivation. It is fine and good enough, provided that you can always maintain the invariants manually needed to design REST API. I am not that brilliant, I need those codegen and metaprogramming features. I am so jealous!

2 Likes

This is awesome!!

The moment I saw modifiers I wanted to have something like this. I think ideally there should be a core libs proposal to expose all the modifier types through Generics, because then it would be extremely easy to extensibly add and use new modifiers.

2 Likes

Is the Modifiers extension less strict than coerce concerning type roles? What if one needs different modifiers for the derivation of different classes? How could the compiler select?

Apparently Foo and FooM are not coercible despite all fields differing only by a newtype wrapper, so this is already strictly more powerful. The only other way not using compiler extensions I see involves expanding the Generically idea via a newtype with rank-1 phantom parameters for record fields, thus simulating the following HKD:

data FooHKD f1 f2 f3 = FooHKD 
  { total     :: !(f1 Int)
  , power     :: !(f2 Int)
  , backwards :: f3 [String]
  , forwards  :: [Int]
  }
type FooM = FooHKD Sum Product Dual
newtype AttFields f1 f2 f3 foo = Fields foo
instance (
  Semigroup (f1 Int),
  Semigroup (f2 Int),
  Semigroup (f3 [String])) 
  => Semigroup (AttFields f1 f2 f3 Foo) where -- like FooHKD f1 f2 f3

deriving via (AttFields Sum Product Dual Foo) instance Semigrop Foo

The advantage of AttFields is that you can annotate different types for different instance derivations.

1 Like

This is awesome!!

Thanks!

I think ideally there should be a core libs proposal to expose all the modifier types through Generics

Yes, I think so too! Indeed, I’m having Codex try to implement modifier-analogue of linear-generics, extending my initial implementation above. It will extend D1, C1 and S1 with additional (heterogeneous) list of Modifiers. Here is the prototype branch:

It also comes with modifier reification mechanism. I hope these experiments foster the discussion of future direction of modifiers :smiling_face:

Is the Modifiers extension less strict than coerce concerning type roles? What if one needs different modifiers for the derivation of different classes? How could the compiler select?

Modifiers extension itself just allows some pieces of code to be annotated by types, and there are no role checking involved. So the above example just utilises that freedom to achieve the field-wise coercion.

The only other way not using compiler extensions I see involves expanding the Generically idea via a newtype with rank-1 phantom parameters for record fields, thus simulating the following HKD:

Yes, I had those form of HKD (or variant involving field as parameter, like data Foo f = Foo { total :: f "total" Int, power :: f "power" Int, ...} in mind as an alternative.

The advantage of AttFields is that you can annotate different types for different instance derivations.

Modifiers allows multiple types to annotate one place, so I think we can also switch representations even with modifier-based approach, if we introduce something like the following:

type ReprFor :: (Type -> Constraint) -> Type -> Constraint
data ReprFor c repr

data Foo = Foo
  { total           %(ReprFor Monoid (Sum Int)) :: Int
  , power           %(ReprFor Monoid (Sum Int)) :: Int
  , -- A Monoid is backwards, but Hashable is forward.
    backwards %(ReprFor Monoid (Dual [String]))
                   %(ReprFor Hashable [String]) :: [String]
  , forwards                                    :: [String]
  }
  deriving (Semigroup, Monoid) via ModifiersOf Foo
2 Likes

I asked because GADTs are notoriously stubborn when it comes to instance deriving. If your system uses the annotation to internally build newtypes that can be safely coerced, that would be an advantage.

1 Like

I asked because GADTs are notoriously stubborn when it comes to instance deriving. If your system uses the annotation to internally build newtypes that can be safely coerced, that would be an advantage.

Ah yes, GADTs are really hard to attack for generic deriving, as they incorporate nontrivial type equality.
I think Template Haskell-side of GitHub - konn/modifier-macros at konn/feat-generics-th · GitHub (or, Template Haskell itself after proper treatment of modifiers landed) could help as TH is always an alternative to generic deriving for GADTs (but requires much complicated AST manipulation).

Normally, I’d agree about writing a manual instance, but one thing that always gets me is a big record type with one field that shouldn’t be Show-n. I don’t want to manually write out the whole Show instance just for this one field

4 Likes

Having a newtype syntax without constructor, which provides behavior like type syntax in terms of pattern matching, but still forces you to derive instances like newtype, could solve the following problem

I believe that Rust’s attribute system for traits deriving was a hacky solution caused by lack of HKTs. In Haskell we can use HKTs and newtypes to fill a type with additional semantics which could be both runtime and compile-time. But lack of powerful coercion system for complex types leads us to overengineer with syntax extensions or lenses (which is completely avoided in Nix for example) to avoid “ripping of constructors” and types repacking

The experiment is cool, but the precedent itself is also sad at the same time, since the Haskell community has to extend the syntax to solve practical problems

3 Likes