@hackage / stock

Stock-style deriving via coercion, with no Generic

Latest0.1.0.2

About

Metadata

  • Last updated , by BaldurBlondal
  • License BSD-3-Clause
  • Categories Type System
  • Maintained by: baldur.blondal@iohk.io

  • Lottery factor: 0

Links

Installation

Tested Compilers

  1. 9.12.2
  2. 9.12.2
  3. 9.12.1
  4. 9.12.1
  5. 9.10.3
  6. 9.10.3
  7. 9.10.2
  8. 9.10.2
  9. 9.10.1
  10. 9.10.1
  11. 9.8.4
  12. 9.8.4
  13. 9.8.3
  14. 9.8.2
  15. 9.8.2
  16. 9.8.1
  17. 9.8.1
  18. 9.6.7
  19. 9.6.7
  20. 9.6.6
  21. 9.6.5
  22. 9.6.4
  23. 9.6.3
  24. 9.6.2
  25. 9.6.1

Readme

stock

Constraint solving plugin that enables extensible, composable deriving without GHC.Generics.

It enables deriving through a Stock(1,2) newtype that the plugin generates instances for at compile-time: Cls (Stock A). This synthesises class instances directly in GHC Core, same as hand-written, with no Generic deriving.

The classes each wrapper synthesises:

  • Stock: Eq, Ord, Show, Read, Semigroup, Monoid, Enum, Bounded, Ix, Generic (that's right)
  • Stock1: Functor / Contravariant, Foldable, Applicative, Generic1, Eq1, Ord1, Show1, Read1, Traversable†, TestEquality, TestCoercion
  • Stock2: Bifunctor, Bifoldable, Eq2, Ord2, Show2, Read2, Category, Bitraversable

(unpack / unboxed-strict fields are rejected, with a clear error)

Traversable / Bitraversable are synthesized at the wrapper (Stock1 F / Stock2 P) and usable directly, or put on your own type with a one-liner:

instance Traversable F where
  traverse g = fmap unStock1 . traverse g . Stock1

A bare deriving via Stock1 F can't reach them: traverse's result f (t b) puts the wrapper under an abstract applicative f, and coercing under an abstract f (nominal role) is unsound — the same wall that stops GeneralizedNewtypeDeriving. The instance itself is perfectly ordinary (it's what GHC's own DeriveTraversable builds), so the one-liner — which re-wraps with a real fmap, not a coercion — gives you the instance, and it honours Override1 / Override2 (which deriving stock Traversable can't).

{-# options_ghc -fplugin Stock #-}

{-# language DerivingVia #-}

import Stock

data Colour = Red | Green | Blue
  deriving (Eq, Ord, Show, Read, Enum, Bounded, Ix) via 
    Stock Colour

data Tree a = Leaf | Node (Tree a) a (Tree a)
  deriving (Eq, Ord, Show) via 
    Stock (Tree a)

data Trio a = Trio Int a [a]
  deriving (Functor, Foldable) via 
    Stock1 Trio

The plugin must be enabled (-fplugin Stock, in ghc-options or a per-file options_ghc).

How it works

For a wanted Cls (Stock T) the plugin unwraps the newtype with its coercion, matches T's constructors and builds the Cls-class dictionary directly as Core, requesting each field's own instance as a fresh wanted (GHC solves Eq Int etc. itself). It's direct synthesis of the wrapped constraint, not delegation.

Per-field modifiers

newtype Override a config = Override a

Override reshapes individual fields during synthesis by specifying their behaviour (zero-cost).

data One = One { x :: Int, y :: Int }
  deriving (Semigroup, Monoid)
  via Overriding One
    [ x via Sum, y via Product ]

combines x additively and y multiplicatively.

There are a few methods of addressing a field, the plugin allows minor notational conveniences.

  • x via F ("x" := F), modifies the field with the F wrapper
  • Int via F (Int := F), modifies every Int field
  • Con at 0 via F (At Con 0 := F), modifies field 0 of constructor
  • 'Con --> F, a path: modifies every field of Con; 'Con --> 0 --> F only its field 0. Each non-terminal hop is a constructor, a position (Nat) or a label (Symbol); the terminal hop is the modifier
  • '[ [F, _] ] ('[ [F, Keep] ]), modifies field 0 only, _ keeps field

Each modifier is either particular via field F Int, a constructor to modify F or a blank _ (or Keep) which leaves a field untouched. A function-typed modifier needs no parentheses: x via a -> f b reads as x := (a -> f b), since via binds looser than ->.

The surface plugin. The lowercase, quote-free surface (x via Sum, Con at 0, the _ blank) is lowered to the honest marker form the solver reads ("x" := Sum, At Con 0, Keep) by the same -fplugin Stock at parse time (Stock.Surface). The generated markers (:=, At, Keep) are qualified to match however you imported Stock.Override, so import Stock.Override qualified as O with O.Override … '[ … via Sum, _ ] resolves too.

Higher-order

Override1 / Override2 lift the same idea over type constructors instead of types.

data Zip a = Zip [a]
  deriving 
    (Functor, Applicative, Foldable) 
  via
  Overriding1 Zip '[ '[ZipList] ]

Adding a class

A companion package introduces a new class Cls for synthesis by writing a DeriveStock Cls instance.

The -fplugin Stock plugin discovers it: looks up the instance, loads the deriver with GHC's own plugin loader, and runs it for deriving Cls via Stock T.

instance DeriveStock Semigroup where 
  deriveStock :: Deriver
  deriveStock = Deriver \cls datatype -> do
    let (<+>) = head (classMethods cls)
    a <- fresh (dtVia datatype) "a"
    b <- fresh (dtVia datatype) "b"
    body <- fromProduct datatype (dtVia datatype) (Var a) \xs ->    -- (match)  a = C x..
            fromProduct datatype (dtVia datatype) (Var b) \ys ->    -- (match)  b = C y..
            toProduct datatype <$> czipFields cls                   -- (build)  C (x <> y)..
              (\ft d x y -> mkApps (Var (<+>)) [Type ft, d, x, y]) (productCon datatype) xs ys
    EvExpr <$> classDictWith cls (dtVia datatype) [] [(0, mkLams [a, b] body)]

The deriver must live in a different module from where it's used. The plugin loads it as compiled code, same-module instances won't work. A normal dependency (separate package, or just a separate module built with -dynamic-too) works.

Performance

cabal bench bench runs identical workloads against a type defined three ways: via Stock, GHC's stock deriving, and hand-written. All three give matching checksums and run within noise of each other — verified by rebuilding and re-running on GHC 9.8 – 9.14:

Ord: sort 100000 3-field records      via Stock 0.075s  stock 0.074s  hand 0.073s
Functor: fmap (+1) x50 over 100000    via Stock 0.136s  stock 0.136s  hand 0.137s

Conclusions / realizations

Every claim here is machine-checked with inspection-testing (it compares optimised Core, not behaviour):

  • Eq, Ord, Enum, Functor, Bounded, Foldable optimise to Core byte-identical to GHC's own deriving on a twin type.
  • Traversable / Bitraversable — which GHC can't stock-derive at all — produce a traverse / bitraverse byte-identical to the natural hand-written definition.
  • every other class provably erases the Stock wrapper and its coercions completely (so it is exactly as fast as hand-written).

In short: where GHC derives the class, you get the same Core GHC emits; where it doesn't, you get the Core you would have written by hand.

Read (and Read1) build readPrec exactly as GHC's derived Read does — the same ReadPrec combinators — and let readsPrec come from the class default, so the result is byte-faithful including the order of ambiguous infix parses. A parity harness (test/Twin.hs) checks the full readsPrec output against GHC's own derived Read on a name-identical twin, over valid / whitespace / parenthesised / negative / garbage inputs at several precedences.

Acknowledgments

Developed with substantial assistance from Claude (Anthropic).

License

BSD-3-Clause.