New notation for records?

  • Mom, can we add a dependency on lenses?
  • We have lenses at home.

Lenses at home:

{-# LANGUAGE AllowAmbiguousTypes, BlockArguments, DuplicateRecordFields, OverloadedLabels #-}

import Control.Monad.State (State, execState, modify)
import GHC.OverloadedLabels (IsLabel(..))
import GHC.Records (HasField(..))

setField :: HasField s a b => b -> a -> a
setField = error "wait for https://gitlab.haskell.org/ghc/ghc/-/issues/16232"

instance (HasField s a b, c ~ ()) => IsLabel s (State b () -> State a c) where
  fromLabel inner = modify (setField @s =<< execState inner . getField @s)

newtype Setter a b = Setter { (.=) :: b -> State a () }

instance HasField s a b => IsLabel s (Setter a b) where
  fromLabel = Setter (modify . setField @s)

(&~) :: a -> State a () -> a
(&~) = flip execState


data Country = Country {name :: String, company :: Company} deriving Show
data Company = Company {name :: String, boss :: Employee, car :: Car} deriving Show
data Employee = Employee {name :: String, age :: Integer, empCar :: Car} deriving Show
data Car = Car {name :: String} deriving Show

f :: Integer -> Country -> Country
f i r =
  r &~ do
    #company do
        #boss do
            #age .= i
        #car do
            #name .= "new"
        #name .= "cmp"
    #name .= "ccc"

The deluxe version (requires 9.6+) even allows you to write:

  r &~ do
    #company do
        #"boss.age" .= i
        #"car.name" .= "new"
        #name .= "cmp"
    #name .= "ccc"

Of course, there is the minor downside that neither actually works yet, but at least there’s a plan for them to work at some point.

4 Likes

I’m afraid the purpose of pointing to that video was lost on me. The guy drivelled on about Java something. If I wanted a decent record system I wouldn’t be going to Java in the first place. So elm [I got this from the tutorial, not the talk] has records built over javascript records (same as purescript). elmers appear not to nest records, but I see no reason why they couldn’t. Javanians do tend to nest records, but again I see no reason why they must.

Again, I didn’t get this from the video, but talk of “dictionary” and “spine” suggests a single indexible attribute. And the video seemed to navigate by a key. This is an even older (and also rejected) structure of database: ‘indexed-sequential’. Rejected because a single indexing path is too restrictive. The Relational Model approach is to store the ‘flat’ tuples separately from any consideration of paths to access it. One application might navigate from Country to Company to Employee, but another might navigate from Employee to Company. Either way round of nesting (as per o.p.) can’t handle all the likely routes of navigation.

“Those who cannot remember the past are condemned to repeat it.” – George Santayana,

(OK, I don’t suppose most round here are old enough to remember the ‘database wars’, but it might occur to you databases are pretty much as old as commercial IT; asking a question from ‘Employee up’ rather than ‘Company down’ pre-dates commercial IT – there were rolodex cards; possibly some people have thought about the challenges.)

That’s amazing.

infixl 4 &~

f :: Integer -> Country -> Country
f i r = r &~ #"company.boss.age" .= i
          &~ #"company.car.name" .= "new car"
          &~ #"company.name"     .= "new company"
          &~ #"name"             .= "new country"

Lens like.

Very cool. State s has the advantage over Writer (Endo s) that one can set and view at the same time.

Looks nice! Instead of relying on OverloadedLabels and parsing the Symbols, an alternative to the deluxe version could repurpose OverloadedRecordDot in combination with two helper values with and the, to let you write something like

f :: Integer -> Country -> Country
f i r =
  r &~ do
    with.company do
      the.boss.age .= i
      the.car.name .= "new"
      the.name .= "cmp"
    the.name .= "ccc"
2 Likes

Similar, using lenses, which will work today - Lenses with OverloadedRecordDot :slight_smile: With optics you can give every Optic a HasField fairly easily and avoid having the separate the thing, ironically bringing it back to how lens code looks (composition with .) at least for nested fields only.

1 Like

Trying to write my own implementation of

$(set ["countryCompany", "companyBoss", "employeeAge"]) i r

in Template Haskell to match

g r i = r { countryCompany = (countryCompany r) { companyBoss = (companyBoss (countryCompany r)) { employeeAge = i } } }

but I just straight up suck at it. :cry:

Why not use a notation, similar to elm? It is a lot more human readable and is roughly as powerful as our current system. And it probably would still keep the door open for extensible/… Records.

Maybe I am alone on this but I dislike the reliance on lenses, arcane syntax and/or another use of do-notation. Why not a simple {a | b = c}, meaning the record a, where the field b has the value c.

(I know, there is a problem around Haskell’s records having a constructor opposed to elm’s, but that is probably a solvable issue)

1 Like

In my opinion, once a typeclass like SetField exists, we should repurpose OverloadedRecordDot for setting, using techniques similar to the ones seen in this thread. I don’t think overloading existing setting syntax, or adding new baked-in syntax, would be necessary.

OP said they were familiar with OverloadedRecordUpdate, but for everyone else, the simple syntax r { foo.bar = 1 } is already reserved and ready to use with an implementation of setField (which you can provide manually or wait for GHC to supply).

The notation is already Elm like.

r { f = a, g = b }
--- vs
{ r | f = a, g = b }

What Elm has that Haskell (98) doesn’t is that it uses maps under the hood for record types and the fields are overloaded.

Elm has actually the same problem I’m bringing up. And they don’t have typeclasses or Template Elm. If they want to use elm-monocle they have to type the lens wiring by hand. The “official response” is that updating deeply nested records is an antipattern and all your data structures should be flat. Or hidden in modules with all the setter pipeline wiring hidden.

3 Likes

Yes I like elm’s notation. The difficulty introducing it into Haskell is backwards compatibility. Consider:

x = foo { a | b = c }
y = Bar { d | e = f }

For the first, the compiler seeing foo { ... } will think that’s a record update to value foo. For the second, the compiler seeing Bar { ... } will think that’s constructor Bar using record syntax – not Bar applied positionally to a single value, that happens to be a record update.

So the choice of notation will have to be module-wide and backwards-incompatible. All those Libraries (including Base/Prelude) will never change. So Haskellers will need to read both notations.

Btw, there is a dormant GHC proposal for introducing row types, but looks like there are some non-trivial unsolved questions because the author decided to turn it into a PhD thesis.

4 Likes

I got better.

Setter.hs:

{-# LANGUAGE TemplateHaskell #-}

module Setter where

import Language.Haskell.TH

set :: [Name] -> Q Exp
set names = return $ LamE [VarP a, VarP r] (go names (VarE r))
    where
        a = mkName "a"
        r = mkName "r"
        go ns acc = case ns of
            [n]    -> RecUpdE acc [(n, VarE a)]
            n : zs -> RecUpdE acc [(n, go zs (AppE (VarE n) acc))]
            _      -> error "no fields?"

And the example.

Main.hs:

{-# LANGUAGE TemplateHaskell #-}

import Setter

data Country = Country { countryName :: String, countryCompany :: Company } deriving (Show)
data Company = Company { companyName :: String, companyBoss :: Employee, companyCar :: Car } deriving (Show)
data Employee = Employee { employeeName :: String, employeeAge :: Integer, employeeCar :: Car } deriving (Show)
data Car = Car { carName :: String } deriving (Show)

example :: Integer -> Country -> Country
example i = $(set ['countryCompany, 'companyBoss, 'employeeAge]) i
          . $(set ['countryCompany, 'companyCar, 'carName]) "new"
          . $(set ['countryCompany, 'companyName]) "cmp"
          . $(set ['countryName]) "ccc"

main :: IO ()
main = do
    let car1 = Car { carName = "car1" }
    let car2 = Car { carName = "car2" }
    let boss = Employee { employeeName = "Carl", employeeAge = 30, employeeCar = car2 }
    let comp = Company { companyName = "Acme", companyBoss = boss, companyCar = car1 }
    let ctry = Country { countryName = "Scotland", countryCompany = comp }
    print ctry
    print $ example 420 ctry

Lenses at home.

1 Like

Out of curiosity, if you’re already eating Template Haskell, what’s stopping you from using actual lenses?

Actually? Nothing, really.

Pros of lenses:

  • nuclear bomb solution
  • versatile, composable

Cons of lenses:

  • nuclear bomb solution, do you really need it?
  • 30 package dependency
  • pollutes the namespace with redundancy on the record accessors with lens counterpart, introduces lens types when initially you just had the record types and the accessors

Pros of my silly script:

  • uses the basics, Haskell98 compatible
  • you drop it off on a file and that’s it, it just works
  • only depends on template-haskell which comes with GHC out of the box, enable the extension and your script is good to go
  • doesn’t need AllowAmbiguousTypes, FunctionalDependencies, UndecidableInstances or others

Cons of my silly script:

  • generates boilerplate which may or may not be optimized to anything sensible
  • you can only set one thing at a time which generates even more boilerplate
  • reducing all the boilerplate introduces quasiquotation and you might as well write setters with the full record notation or use lenses if you’re going to bother with a parser
  • why not use vinyl or record?

I will keep it on a file of utils, for scripts.

See Optics.Generic and Optics.Label. lens isn’t the only library with a solution and this gets rid of (most of?) your cons.

1 Like

optics has an impressively small dependency graph. i think they all ship with ghc? de facto stdlib stuff

2 Likes

It is in fact a pretty nifty alternative to lens.

{-# LANGUAGE DuplicateRecordFields, TemplateHaskell, DataKinds, TypeFamilies, UndecidableInstances, OverloadedLabels #-}

import Optics
import Optics.TH

data Country = Country { name :: String, company :: Company } deriving (Show)
data Company = Company { name :: String, boss :: Employee, car :: Car } deriving (Show)
data Employee = Employee { name :: String, age :: Integer, car :: Car } deriving (Show)
data Car = Car { name :: String } deriving (Show)

makeFieldLabelsNoPrefix ''Country
makeFieldLabelsNoPrefix ''Company
makeFieldLabelsNoPrefix ''Employee
makeFieldLabelsNoPrefix ''Car

example :: Integer -> Country -> Country
example i = set (#company % #boss % #age) i
          . set (#company % #car % #name) "Batmobile"
          . set (#company % #name) "Aperture"
          . set (#name) "Canada"

main :: IO ()
main = do
    let car1 = Car { name = "car1" }
    let car2 = Car { name = "car2" }
    let boss = Employee { name = "Carl", age = 30, car = car2 }
    let comp = Company { name = "Acme", boss = boss, car = car1 }
    let ctry = Country { name = "Scotland", company = comp }
    print ctry
    print $ example 420 ctry

Makes replacing lenses easy and also quite as handy. You can choose Generics, TH, symbol spaghetti or lens like interface.

1 Like

For Template Haskell learning purposes I went and downloaded this and used Template Haskell to provide an overloading function to generate all the boilerplate for a record that wants to use OverloadedRecordUpdate.

Experimental.hs:

{-# LANGUAGE AllowAmbiguousTypes, FunctionalDependencies, TemplateHaskell #-}

module Experimental (
    HasField (..),
    getField,
    setField,
    overloadRecords
) where

import Language.Haskell.TH

class HasField x r a | x r -> a where
    -- | Function to get and set a field in a record.
    hasField :: r -> (a -> r, a)

getField :: forall x r a. (HasField x r a) => r -> a
getField = snd . hasField @x

setField :: forall x r a. (HasField x r a) => r -> a -> r
setField = fst . hasField @x

overloadRecords :: [Name] -> Q [Dec]
overloadRecords tNames = do
    foldr (\tName qDecs -> do
        nDecs <- overloadRecord tName
        pDecs <- qDecs
        return (pDecs ++ nDecs)) (return []) tNames

overloadRecord :: Name -> Q [Dec]
overloadRecord tName = do
    tDec <- getTypeDec
    dCons <- getDataCons tDec
    foldr (\a b -> do
        xd <- inst a (getCompleteType tDec) dCons
        xp <- b
        return (xd ++ xp)) (return []) (agroupFs (extractFs dCons) [])
    where
        getTypeDec = do
            i <- reify tName
            case i of
                TyConI d -> return d
                _        -> error $ "unsupported type " ++ show tName
        getDataCons td = case td of
            DataD _ _ _ _ cs _ -> return cs
            _                  -> error $ "no cons for " ++ show tName
        getCompleteType td = case td of
            DataD _ t ks _ _ _ -> mf (fmap kf ks) t
            _                  -> error $ "no kinds for " ++ show tName
            where
                kf (PlainTV n _) = n
                kf (KindedTV n _ _) = n
                mf ps t = case ps of
                    [x]    -> appT (conT t) (varT x)
                    x : xs -> appT (mf xs t) (varT x)
                    _      -> conT t
        extractFs rs = case rs of
            r : xs -> z r ++ extractFs xs
            _      -> []
            where
                z (RecC c fs) = fmap (\(n, _, t) -> (n, t, c)) fs
                z _           = []
        agroupFs xs acc = case xs of
            []             -> acc
            (n, t, c) : ys -> inc (\(n2, t2, c2) -> (n2, t2, c : c2)) (\(n2, _, _) -> n2 == n) (n, t, [c]) (agroupFs ys acc)
            where
                inc z h x zs = case span (not . h) zs of
                    (_, [])     -> x : zs
                    (a, b : bs) -> a ++ (z b : bs)
        inst (fieldName, fieldType, dataCons) fullT dCons =
            [d|
                instance HasField $(litT (strTyLit fieldStr)) $(fullT) $(return fieldType) where
                    hasField r = $(caseE [| r |] matches)
              |]
            where
                fieldStr = reverse . takeWhile (/= '.') . reverse . show $ fieldName
                o = "v"
                matches = fmap (\dcn -> let vats = fmap varP (mt dCons dcn)
                                            pats = conP dcn vats
                                            body = normalB [| (\x -> $(vt dCons dcn [| x |]), $(varE (mkName o))) |]
                                        in match pats body []) dataCons ++ [fin]
                    where
                        err = "No match in record selector " ++ fieldStr
                        fin = match wildP (normalB [| error $(litE (stringL err)) |]) []
                mt rs consName = case rs of
                    (RecC c fs) : xs -> if consName == c then p fs 0 else mt xs consName
                    _                -> []
                    where
                        p ((n, _, _) : js) num = (if n == fieldName then mkName o else mkName (o ++ show num)) : p js (num + 1)
                        p _ _                  = []
                vt rs consName xq = case rs of
                    (RecC c fs) : xs -> if consName == c then p (prep fs) else vt xs consName xq
                    _                -> conE consName
                    where
                        prep = reverse . zip [0..] . fmap (\(n, _, _) -> n)
                        p [] = conE consName
                        p ((i, x) : xs) = appE (p xs) (if x == fieldName then xq else varE (mkName (o ++ show i)))

{-
instance "name" Country String where
    hasField r = case r of
        Country v v1 -> (\x -> Country x v1, v)
        AlienCountry v v1 -> (\x -> Country x v1, v)
        _ -> error "no match"
-}

Main.hs:

{-# LANGUAGE DuplicateRecordFields, OverloadedRecordDot, OverloadedRecordUpdate, RebindableSyntax #-}
{-# LANGUAGE DataKinds, TemplateHaskell #-}

import Prelude
import Experimental

data Country a = Country { name :: String, company :: Company } | AlienCountry { name :: String, poly :: a } deriving (Show)
data Company = Company { name :: String, boss :: Employee, car :: Car } deriving (Show)
data Employee = Employee { name :: String, age :: Integer, car :: Car } deriving (Show)
data Car = Car { name :: String } deriving (Show)

overloadRecords [''Country, ''Company, ''Employee, ''Car]

main :: IO ()
main = do
    let car1 = Car { name = "car1" }
    let car2 = Car { name = "car2" }
    let boss = Employee { name = "Carl", age = 30, car = car2 }
    let comp = Company { name = "Acme", boss = boss, car = car1 }
    let ctry = Country { name = "Scotland", company = comp }
    print (ctry :: Country String)
    print $ ctry.company.boss.age
    let uc = ctry { company.boss.age = 420, company.boss.car.name = "new car" }
    print (uc :: Country String)
    let alien = AlienCountry { name = "Canada", poly = Company { name = "Alien Corp", boss = boss, car = car2 }}
    print alien
    print $ alien.poly.name
    let ac = alien { name = "Brazil", poly.boss.age = 999 }
    print ac

Only to realize I was using the old proposal and not the new one and I can’t just flip the types to finally see the “typechecked only” example work (because of ambiguous errors).

The good thing is that my example just works, like how original records do. Which is good… and bad. Good because it works, with the simple ergonomics that implies. Bad because there’s no type checking that the member you are calling is effectively part of the value (e.g asking for the company of AlienCountry is valid in old Haskell, valid here, and gives a runtime error if you do so).

Now I’m wondering how it would look like with optics.