Skip to content

Instantly share code, notes, and snippets.

@danidiaz
Last active January 17, 2020 16:41
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save danidiaz/2157e68f5d4967e468a9d062d4476adf to your computer and use it in GitHub Desktop.
Save danidiaz/2157e68f5d4967e468a9d062d4476adf to your computer and use it in GitHub Desktop.
A GADT for chains of functions, that remembers the types of the intermediate steps using a type-level list. https://www.reddit.com/r/haskell/comments/ei5kek/monthly_hask_anything_january_2020/fcpeppz/
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
module Pipy where
import Data.Kind (Type)
import GHC.TypeLits
import Data.Char (isAlpha)
-- GADT containing a chain of functions, and which remembers the types of each internal step.
data Pipeline (start :: Type) (middle :: [Type]) (end :: Type) where
-- A pipeline built from a plain function doesn't have internal steps
Simple :: (start -> end) -> Pipeline start '[] end
Composite :: (start -> head) -> Pipeline head middle end -> Pipeline start (head : middle) end
-- Forget about the internal steps, give me a plain function from start to end
runPipeline :: Pipeline start middle end -> start -> end
runPipeline (Simple f) = f
runPipeline (Composite f rest) = runPipeline rest . f
-- "tail" for pipelines is easy
tailPipeline :: Pipeline start (head : middle) end -> Pipeline head middle end
tailPipeline (Composite _ rest) = rest
-- "init" for pipelines is a bit harder, and in order to define it we need this
-- typeclass.
--
-- Notice that the instances hang on the type-level lists that
-- represent the internal steps of the pipeline.
--
-- Notice also that there's no instance for the empty list '[], as it wouldn't
-- make sense.
class Snoc (xs :: [Type]) where
type Init xs :: [Type] -- all elements of the argument list minus the last
type Last xs :: Type
initPipeline :: Pipeline start xs end -> Pipeline start (Init xs) (Last xs)
-- Pipelines with only one internal step are the base case.
instance Snoc '[x] where
type Init '[x] = '[]
type Last '[x] = x
initPipeline (Composite start _) = Simple start
-- Instance for pipelines with more than one internal step.
instance Snoc (y : zs) => Snoc (x : y : zs) where
type Init (x : y : zs) = x : Init (y : zs)
type Last (x : y : zs) = Last (y : zs)
initPipeline (Composite start rest) = Composite start (initPipeline rest)
-- Examples
example1 :: Pipeline String '[Char] Bool
example1 = Composite head (Simple isAlpha)
example2 :: Pipeline Char '[] Bool
example2 = tailPipeline example1
example3 :: Pipeline String '[] Char
example3 = initPipeline example1
runExample1 :: String -> Bool
runExample1 = runPipeline example1
main :: IO ()
main = pure ()
-- (This gist depends on the "red-black-record" package. http://hackage.haskell.org/package/red-black-record)
-- This version defines pipelines with the help of a type-level graph.
-- We can edit individual nodes of the graph before generating the pipeline.
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE UndecidableInstances #-}
module Pipy where
import Data.Kind (Type)
import Data.Type.Equality (type (==))
import Data.Proxy
import Data.RBR (Map,Record,Key,Value,project,insert,unit,delete,setField) -- from "red-black-record"
import GHC.TypeLits
-- To be used lifted, as a kind.
-- The 'c' variable is a pointer to the next node in the pipeline, if it exists.
data Segment a b c = Segment a b (Maybe c)
-- A type-level graph
type Graph = Map Symbol (Segment Type Type Symbol)
type family SegmentFunction (s :: Segment Type Type n) where
SegmentFunction ('Segment a b _) = a -> b
-- Helper newtype. For a given segment, wrap the function associated to it.
newtype Y (s :: Segment Type Type x) = Y {unY :: SegmentFunction s}
-- Extract a pipeline by entereing the graph at a given node and following the
-- pointers to other nodes.
class ExtractPipeline (k :: Symbol) (g :: Graph) where
type Start k g :: Type
type End k g :: Type
-- The Record is a datatype indexed by the graph, where each field comes
-- wrapped in a an Y parameterized by a segment.
-- In other words: it's a record of functions.
extractPipeline :: Proxy k -> Record Y g -> Start k g -> End k g
-- Auxiliary typeclass to avoid the need for OverlappinInstances.
class ExtractPipeline_ (s :: Segment Type Type Symbol) (g :: Graph) where
type Start_ s g :: Type
type End_ s g :: Type
extractPipeline_ :: Proxy s -> Y s -> Record Y g -> Start_ s g -> End_ s g
-- The main class delegates on the auxiliary one
instance (Key k g, ExtractPipeline_ (Value k g) g) => ExtractPipeline k g where
type Start k g = Start_ (Value k g) g
type End k g = End_ (Value k g) g
extractPipeline _ r = extractPipeline_ (Proxy @(Value k g)) (project @k r) r
-- Simplest case: extract a piepline from a final node.
instance ExtractPipeline_ ('Segment a b 'Nothing) g where
type Start_ ('Segment a b Nothing) g = a
type End_ ('Segment a b Nothing) g = b
extractPipeline_ _ (Y f) _ = f
-- Recursive case: extract a piepline from a node with a "next" pointer.
-- We check that the start type of the next segment is equal to the end type of the current one.
instance (ExtractPipeline next g, Start next g ~ b) => ExtractPipeline_ ('Segment a b ('Just next)) g where
type Start_ ('Segment a b ('Just next)) g = a
type End_ ('Segment a b ('Just next)) g = End next g
extractPipeline_ _ (Y f) r = extractPipeline (Proxy @next) r . f
main :: IO ()
main = do
let r = insert @"start" @('Segment [Int] Int (Just "middle")) (Y head)
. insert @"middle" @('Segment Int Int (Just "end")) (Y succ)
. insert @"end" @('Segment Int String 'Nothing) (Y show)
$ unit
print $ extractPipeline (Proxy @"start") r [1,2,3]
print $ extractPipeline (Proxy @"middle") r 2
print $ extractPipeline (Proxy @"end") r 1
--
-- changing the function for a node is relatively easy
let r2 = setField @"middle" (Y (succ . succ)) r
print $ extractPipeline (Proxy @"middle") r2 2
--
-- Changing a pointer to a "next node" is more involved.
-- We must delete and reinsert the field in the record
-- because a limitation in red-black-record.
-- Much boilerplate :(
let Y f = project @"start" r
r3 = insert @"start" @('Segment [Int] Int (Just "end")) (Y f)
. delete @"start" @('Segment [Int] Int (Just "middle"))
$ r
print $ extractPipeline (Proxy @"start") r3 [1,2,3]
-- (This gist depends on the "red-black-record" package. http://hackage.haskell.org/package/red-black-record)
-- Inspired by https://www.reddit.com/r/haskell/comments/ei5kek/monthly_hask_anything_january_2020/fcpeppz/
-- This version defines pipelines of functions with the help of a type-level graph.
-- We can edit individual nodes of the graph before extracting the pipeline.
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE AllowAmbiguousTypes #-}
module Pipy where
import Data.Kind (Type)
import Data.Type.Equality (type (==))
import Data.Proxy
-- from "red-black-record"
import Data.RBR (Map -- type-level map
,Empty -- the empty map
,FromList -- construct a type-level map from type-level list of tuples
,Key(Value) -- is a key in the type-level map?
,Insertable(Insert) -- can we insert this k/v combination into the typelevel map?
,Deletable(Delete) -- can we delete this k/v combination into the typelevel map?
,Record -- record indexed by a type-level map
,unit -- trivial record without fileds
,project -- get field from record
,insert -- add field to record
,setField) -- modify a field in the record without changing types)
import GHC.TypeLits
-- We have two type-level maps, with the same keys.
-- One determines the types of each pipeline segment.
-- The other determines the links between segments.
-- Formerly it was a single map, but it was more cumbersome that way.
-- But having two maps will make compilation slower :(
-- To be used lifted, as a kind.
-- Used to constrain the Record to contain only functions, and not arbitrary types.
data Segment a b = Segment a b
-- A type-level graph which defined connections between nodes. Purely type-level info.
type Graph = Map Symbol (Maybe Symbol)
-- A type-level map of node info. Will index a term-level record carrying the
-- actual functions of the pipeline.
type Nodes = Map Symbol (Segment Type Type)
-- Get the function type which corresponds to the segment
type family SegmentFunction (s :: Segment Type Type) :: Type where
SegmentFunction ('Segment a b) = a -> b
-- Helper newtype. For a given segment, wrap the function associated to it.
newtype Y (s :: Segment Type Type) = Y {unY :: SegmentFunction s}
-- Extract a pipeline by entering the graph at a given node and following the
-- pointers to other nodes.
class ExtractPipeline (k :: Symbol) (g :: Graph) (n :: Nodes) where
type Start k g n:: Type
type End k g n :: Type
-- The Record is a datatype indexed by the node map. Each field comes
-- wrapped in a an Y parameterized by a segment. In other words: it's a
-- record of functions.
extractPipeline :: Record Y n -> Start k g n -> End k g n
-- Auxiliary typeclass to avoid the need for OverlappingInstances.
class ExtractPipeline_ (s :: Segment Type Type) (next :: Maybe Symbol) (g :: Graph) (n :: Nodes) where
type Start_ s next g n :: Type
type End_ s next g n :: Type
extractPipeline_ :: Y s -> Record Y n -> Start_ s next g n -> End_ s next g n
-- The main class delegates on the auxiliary one
instance (Key k g, Key k n, ExtractPipeline_ (Value k n) (Value k g) g n) => ExtractPipeline k g n where
type Start k g n = Start_ (Value k n) (Value k g) g n
type End k g n = End_ (Value k n) (Value k g) g n
extractPipeline r = extractPipeline_ @(Value k n) @(Value k g) @g @n (project @k r) r
-- Simplest case: extract a pipeline from a final node.
instance ExtractPipeline_ ('Segment a b) 'Nothing g n where
type Start_ ('Segment a b) Nothing g n = a
type End_ ('Segment a b) Nothing g n = b -- b is the final type because there are no further nodes
extractPipeline_ (Y f) _ = f
-- Recursive case: extract a pipeline from a node with a "next" pointer.
-- We check that the start type of the next segment is equal to the end type of the current one.
instance (ExtractPipeline next g n, Start next g n ~ b) => ExtractPipeline_ ('Segment a b) (Just next) g n where
type Start_ ('Segment a b) ('Just next) g n = a
type End_ ('Segment a b ) ('Just next) g n = End next g n
extractPipeline_ (Y f) r = extractPipeline @next @g @n r . f
-- helper for adding a segment to the record of functions
putfun :: forall k a b n . Insertable k ('Segment a b) n => (a -> b) -> Record Y n -> Record Y (Insert k ('Segment a b) n)
putfun f = insert @k @('Segment a b) (Y f)
-- modify a function in the pipeline, but keeping the same type
setfun :: forall k a b n . (Key k n, Value k n ~ 'Segment a b) => (a -> b) -> Record Y n -> Record Y n
setfun f = setField @k (Y f)
-- Helper to make constructing linear paths easier
type family FromPath (path :: [Symbol]) :: Map Symbol (Maybe Symbol) where
FromPath '[x] = Insert x Nothing Empty
FromPath (x : y : zs) = Insert x (Just y) (FromPath (y : zs))
-- Helpers to modify paths in the type-level graph.
-- Delete helper that uses Key to avoid having to mention the value
type Delete' (k :: Symbol) (t :: Map Symbol q) = Delete k (Value k t) t
type Cut (key :: Symbol) (g :: Graph) = Insert key Nothing (Delete' key g)
type Extend (key :: Symbol) (new :: Symbol) (g :: Graph) = Insert key (Just new) (Delete key Nothing g)
type Redirect (key :: Symbol) (new :: Symbol) (g :: Graph) = Insert key (Just new) (Delete' key g)
-- Example
-- type Graph01 = FromList [ '("start", Just "middle")
-- , '("middle", Just "end")
-- , '("end", Nothing)
-- ]
type Graph01 = FromPath [ "start", "middle", "end" ]
main :: IO ()
main = do
let r = putfun @"start" @[Int] @Int head
. putfun @"middle" @Int @Int succ
. putfun @"end" @Int @String show
$ unit
print $ extractPipeline @"start" @Graph01 r [1,2,3]
print $ extractPipeline @"middle" @Graph01 r 2
print $ extractPipeline @"end" @Graph01 r 1
-- starting from the middle
print $ extractPipeline @"middle" @Graph01 r 2
-- cutting the end
print $ extractPipeline @"start" @(Cut "middle" Graph01) r [1,2,3]
-- changing the function for a node is relatively easy
print $ extractPipeline @"start" @Graph01 (setfun @"middle" (succ . succ) r) [1,2,3]
-- cutting the pipeline earlier
print $ extractPipeline @"start" @(Redirect "start" "end" Graph01) r [1,2,3]
-- Inspired by Plucking Constraints https://www.parsonsmatt.org/2020/01/03/plucking_constraints.html
-- How to avoid the bolierplate?
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE TypeOperators #-}
module Plucky where
import Data.Type.Equality (type (==))
class AsError e es where
liftError :: e -> es
isError :: es -> Maybe e
data Pluck e es =
This e
| Other es
-- Auxiliary class to avoid OverlappingInstances. AsError delegates on this.
-- It has an additional Bool argument to distinguish if the head matches.
-- Maybe using OverlappingInstances would be easier?
class AsError_ (b :: Bool) e es where
liftError_ :: e -> es
isError_ :: es -> Maybe e
-- Yes, it matches the head
instance AsError_ True e (Pluck e es) where
liftError_ = This
isError_ (This e) = Just e
isError_ (Other es) = Nothing
-- Nah, look elsewhere
instance AsError e' es => AsError_ False e' (Pluck e es) where
liftError_ e' = Other (liftError @e' @es e')
isError_ (This _) = Nothing
isError_ (Other es) = isError @e' @es es
-- AsError delegates on AsError_
instance AsError_ (e' == e) e' (Pluck e es) => AsError e' (Pluck e es) where
liftError = liftError_ @(e' == e)
isError = isError_ @(e' == e)
--
--
--
data DbError = DbError
data HttpError = HttpError
data FileError = FileError
program
:: (AsError HttpError e, AsError DbError e)
=> Either e ()
program = do
Left (liftError HttpError)
Left (liftError DbError)
handleLeft :: forall err err' a . Either err a -> (err -> Either err' a) -> Either err' a
handleLeft (Right r) _ = Right r
handleLeft (Left l) f = f l
program'' :: forall e. AsError HttpError e => Either e ()
program'' =
handleLeft @(Pluck DbError e) program $ \err ->
case err of
This dbError ->
Right ()
Other dbOther ->
Left dbOther
--
-- helper class that could be useful when choosing a concrete error carrier
data ErrorABC a b c =
ErrorA a
| ErrorB b
| ErrorC c
instance AsError a (ErrorABC a b c) where
liftError = ErrorA
isError (ErrorA a) = Just a
isError _ = Nothing
instance AsError b (ErrorABC a b c) where
liftError = ErrorB
isError (ErrorB b) = Just b
isError _ = Nothing
instance AsError c (ErrorABC a b c) where
liftError = ErrorC
isError (ErrorC c) = Just c
isError _ = Nothing
foo1,foo2,foo3 :: ErrorABC Int Bool Char
foo1 = liftError (5::Int)
foo2 = liftError True
foo3 = liftError 'a'
main :: IO ()
main = do
return ()
-- A failed attempt at automating "plucking constraints" for errors:
-- https://www.reddit.com/r/haskell/comments/ejjnss/plucking_constraints/
-- It bogged down because my type-level maps are really dumb.
-- "Your type level map has a key A. You deleted key B. What? You expect me to know that the map still has A?"
-- This gist depends on the "red-black-record" package.
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE PolyKinds #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE MultiParamTypeClasses #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE UndecidableInstances #-}
{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE AllowAmbiguousTypes #-}
module Pipy where
import Data.Kind (Type)
import Data.Type.Equality (type (==))
import Data.Proxy
import Data.RBR (Map,Record,Key,Value,project,insert,unit,delete,setField,Deletable(..),Insertable(..),I(..),Variant,winnowI)
import GHC.TypeLits
type HasError (name :: Symbol) (error :: Type) (t :: Map Symbol Type) = (Key name t, Value name t ~ error)
type LacksError (name :: Symbol) (error :: Type) (t :: Map Symbol Type) (t' :: Map Symbol Type) = (HasError name error t', Deletable name error t', Delete name error t' ~ t)
data ErrorA = ErrorA
data ErrorB = ErrorB
data ErrorC = ErrorC
funcErrorBig :: forall g . (HasError "A" ErrorA g, HasError "B" ErrorB g, HasError "C" ErrorC g) => Either (Variant I g) ()
funcErrorBig = undefined
-- How to prove: if a map contains a, b!=a, and we delete b from the map, a is still present.
funcErrorSmall :: forall t t'. (HasError "A" ErrorA t,
HasError "A" ErrorA t',
HasError "B" ErrorB t,
HasError "B" ErrorB t',
LacksError "C" ErrorC t t')
=> Either (Variant I t) ()
funcErrorSmall =
case funcErrorBig @t' of
Right () -> Right ()
Left e -> case winnowI @"C" @ErrorC @t' e of
Left smaller -> Left smaller
Right w -> Right ()
main :: IO ()
main = do
return ()
-- A newtype indexed by a sum type lifted with DataKinds.
-- Inspired by https://www.reddit.com/r/haskell/comments/elh8hl/newtype_or_tagged_type/
-- See also
-- http://oleg.fi/gists/posts/2019-03-21-flag.html
-- https://hackage.haskell.org/package/safe-money
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE AllowAmbiguousTypes #-}
module Main (main) where
-- To be used as a kind
data Scale = Celsius | Fahrenheit
--
-- Annoying boilerplate, I want dependent Haskell to be able to demote
-- without boilerplate or TH
class DemoteScale (s :: Scale) where
demoteScale :: Scale
instance DemoteScale Celsius where
demoteScale = Celsius
instance DemoteScale Fahrenheit where
demoteScale = Fahrenheit
--
-- constructor likely not exported
newtype Temperature (s :: Scale) = Temperature Int deriving (Show,Eq,Ord)
makeTemperature :: forall (s :: Scale) . Int -> Temperature s
makeTemperature = Temperature
getTemperatureLevel :: Temperature s -> Int
getTemperatureLevel (Temperature i) = i
--
prettyPrintTemperature :: forall s. DemoteScale s => Temperature s -> String
prettyPrintTemperature t =
let unit = case demoteScale @s of
Celsius -> "°C"
Fahrenheit -> "°F"
in show (getTemperatureLevel t) ++ " " ++ unit
main :: IO ()
main = do
putStrLn $ prettyPrintTemperature $ makeTemperature @Celsius 3
putStrLn $ prettyPrintTemperature $ makeTemperature @Fahrenheit 3
@rampion
Copy link

rampion commented Jan 16, 2020

Haven't finished reading your code yet, but your description reminds me of my cascades toy project from a couple years ago:

Cascades are collections of composable functions (e.g. a -> b, b -> c, ... , y -> z) where the intermediate types are stored in a type level list (e.g. Cascade [a,b,c,...,y,z]).

@danidiaz
Copy link
Author

@rampion It does seem like the same idea! I like the idea in Cascade of using a record type for storing the intermediate results.

I eventually went a bit overboard and in Pipelines3.hs I used a type-level map to have named stages.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment