A rationale behind this attempt
Why would I want to attempt to create a monoid-like thing for records? Well, the reasoning is three-fold. First, when playing around with Purescript, I often have these large record definitions and then I have to initialize them. I would much rather just say "This record has all fields empty, figure it out." Second, these large records are often the heart of some state, that I update all the time, and I would much rather just do
updateFunction state update = appendRecord state update
than
updateFunction state update = state {a = state.a <> update.a ...}
Third, because Record is kind-of like a row-polymorphic Tuple, and Tuple has a Monoid instance, it should mean that creating a something similar for Records should be quite an easy puzzle to solve :-)
First the memptyRecord
Because I wouldn't be implementing a proper instance (still not entirely sure that is possible just yet in Purescript, would I need overlapping typeclass instance support to do that?), and just the two hepler functions, that more or less try to convey the implementation of the instance in spirit, I start with the memptyRecord implementation.
It turns out it is easier than the appendRecord one.
For the class itself, I just need the RowList and output row.
class MemptyRecord rl row | rl -> row
where
memptyRecordImpl :: RLProxy rl -> Record row
And Nil case will be just an empty record.
instance memptyRecordNil :: MemptyRecord Nil () where
memptyRecordImpl _ = {}
In the Cons instance, I just iterate over the RowList
, on constraint that every there is a Monoid
in every row and insert a mempty
for that row.
instance memptyRecordCons ::
( IsSymbol name
, Monoid t
, MemptyRecord tail tailRow
, RowLacks name tailRow
, RowCons name t tailRow row
) => MemptyRecord (Cons name t tail) row where
memptyRecordImpl _ =
insert namep mempty rest
where
namep = SProxy :: SProxy name
tailp = RLProxy :: RLProxy tail
rest = memptyRecordImpl tailp
To tie it all together, I wrap the memptyRecordImpl in memptyRecord function, that makes the compiler to figure out the row-list for me.
memptyRecord :: forall rl row . RowToList row rl
=> MemptyRecord rl row
=> Record row
memptyRecord = memptyRecordImpl (RLProxy :: RLProxy rl)
In the end, this was fairly simple and I managed to solve the first problem, because now
{a : "", b : []} == memptyRecord :: {a :: String, b :: Array Int}
This of-course doesn't really work on nested records, but I think it is still an improvement.
Now, the appendRecord
For the sake of simplicity I will just dump the entirety of my first attempt at this. It actually worked and
~appendRecord {a: "1", b: [2], c: "3"} {a: "a", b: [4], c: "c"} == {a: "1a", b: [2,4], c: "3c"}~
It looked like this:
+class SemigroupRecord rl row row'
| rl -> row row'
where
appendRecordImpl :: RLProxy rl -> Record row -> Record row -> Record row'
instance appendRecordCons ::
( IsSymbol name
, Semigroup ty
, RowCons name ty trash row
, SemigroupRecord tail row tailRow'
, RowLacks name tailRow'
, RowCons name ty tailRow' row'
) => SemigroupRecord (Cons name ty tail) row row' where
appendRecordImpl _ a b =
insert namep (valA <> valB) rest
where
namep = SProxy :: SProxy name
valA = get namep a
valB = get namep b
rest = appendRecordImpl (RLProxy :: RLProxy tail) a b
instance appendRecordNil :: SemigroupRecord Nil row () where
appendRecordImpl _ _ _ = {}
This implementation required, that when I use appendRecord
, both records have the same keys, and that didn't really fit the record-updating semantics I had in mind.
If I had a large record to update, I don't really want to specify all of the keys, I just want to specify the keys that I want to update.
With this in mind, I went to #purescript on functionalprogramming.slack.com and started asking, if anybody knows how to implement outer join of two records.
If I knew, that you could somehow merge {a: 1, b:"b"}
with {b:"b", c:"c"}
to get {a:1, b:"b", c:"c" }
, changing it do {a:1, b:"bb", c:"c" }
should be simple, right?
Unfortunately, it turns out, doing outer-join with RowLists seems to be hard, mostly ending the conversation about my outer-join ideas with the question "So, why do you actually want to do that?"
After I described the use-case for updating records, @monoidmusician suggested to change the appendRecord
function to allow the second record be a subset of first one. This means that the appendRecordImpl
type becomes
RLProxy rl -> Record big -> Record small -> Record big
This makes everything simpler, and @paluh even sent me an implementation in gist.
First interesting thing I have noticed, that with the new class definition, I don't actually need any functional dependencies.
class AppendSubrecordImpl rl bigger smaller where
appendSubrecordImpl :: RLProxy rl -> Record bigger -> Record smaller -> Record bigger
Because the iteration happens over the smaller record, in nil case I just return the bigger record.
instance appendSubrecordNil :: AppendSubrecordImpl Nil bigger smaller where
appendSubrecordImpl _ b s = b
And because I know that the result will be the Row bigger
, I don't actually need the machinery to build up the output row.
instance appendSubrecordCons ::
( IsSymbol name
, RowCons name t trash smaller
, RowCons name t trash' bigger
, Semigroup t
, AppendSubrecordImpl tail bigger smaller
) => AppendSubrecordImpl (Cons name t tail) bigger smaller where
appendSubrecordImpl _ bigger smaller = modify key modifier rest
where
key = SProxy :: SProxy name
modifier v = v <> get key smaller
rest = appendSubrecordImpl (RLProxy ∷ RLProxy tail) bigger smaller
One day, I maybe will be bugging @jusrin00 to merge this PR as well :-)
Could this still be a monoid?
Now that I have what I wanted, I started thinking if this still can be considered a monoid. If we would have one well defined type, that would go through all of our functions, a single closed row-type, I don't think there would be a problem with any of the monoid laws and fairly easily we could do:
newtype User = User {name: Maybe String, surname: Maybe String}
instance semigroupUser :: Semigroup User where
append (User a) (User b) = User (appendSubrecord a b)
instance monoidUser :: Monoid User where
mempty = User {name : Nothing, surname: Nothing}
But I am not really sure what does the fact, that we relaxed the second row to be a sub-set of the first actually mean. It seems that there might be some foot-guns hidden in there.
For example, because I only iterate through the smaller row RowList, the first row could contain keys that aren't Semigroups. Is that a bug, or a feature? I am not sure yet,
but I definitely can't claim that {a:String, b:MyNotASemigroup}
is a Semigroup
, even though I could appendSubrecord {a:"Adam", b:Test} {a: " Saleh"}
. The compiler would still catch,
if I try to append the wrong key. On the whole, this starts to resemble more of a relational algebra, than the group-hierarchy. I wonder how would rest of the instances from Tuple look like,
if I ported them over to Record. Maybe I will try to take a stab at Row-polymorphic curry and uncurry, that might be fun.