开发者

How can I promote code reuse in a manner similar to mixins/method modifiers/traits in other languages?

开发者 https://www.devze.com 2023-03-31 19:24 出处:网络
I\'m working on some code that interfaces to a database schema that models a persistent graph. Before I go into the details of my specific question, I thought it might help to provide some motivation.

I'm working on some code that interfaces to a database schema that models a persistent graph. Before I go into the details of my specific question, I thought it might help to provide some motivation. My schema is around books, people and author roles. A book has many author roles, where each role has a person. However, instead of allowing direct UPDATE queries on book objects, you must create a new book, and make modifications to the new version.

Now, back to Haskell land. I am currently working with a few type classes, but importantly I have HasRoles and Entity:

class HasRoles a where
    -- Get all roles for a specific 'a'
    getRoles :: a -> IO [Role]

class Entity a where
    -- Update an entity with a new entity. Return the new entity.
    update :: a -> a -> IO a

Here comes my problem. When you are updating a book, you need to create a new book version but you also need to copy over the previous books roles (otherwise you lose data). The simplest way to do this is:

instance Entity Book where
    update orig newV = insertVersion V >>= copyBookRoles orig

This is fine, but there's something that bugs me, and that's the lack of any guarantee of the invariant that if something is an Entity and HasRoles, then inserting a new version will copy over the existing roles. I have thought of 2 options:

Use More Types

One 'solution' is to introduce the RequiresMoreWork a b. Going from the above, insertVersion now returns a HasRoles w => RequiresMoreWork w Book. update wants a Book, so to get out of the RequiresMoreWork value, we could call workComplete :: RequiresMoreWork () Book -> Book .

The real problem with this though, is that the most important piece of the puzzle is the type signature of insertVersion. If this doesn't match the invariants (for example, it made no mention of needing HasRoles) then it all falls apart again, and 开发者_运维技巧we're back to violating an invariant.

Prove it with QuickCheck

Moves the problem out of compile time, but at least we're still asserting the invariant. In this case, the invariant is something like: for all entities that are also instances of HasRoles, inserting a new version of an existing value should have the same roles.


I'm a bit stumped on this. In Lisp I'd use method modifiers, in Perl I'd use roles, but is there anything I can use in Haskell?


Dealing in the specific, I would make the roles part of a type instead of an class

data Rolled a = Rolled a [Role]

instance Entity a => Entity (Rolled a) where update (Rolled a rs) = Rolled (update a) rs

More generally, you could just make pairs instances of Entity

I haven't reached haskell zen, but I would guess you should end up working in the Writer or State monad (or their transformer versions)


I'm of two minds as to how I should respond to this:

This is fine, but there's something that bugs me, and that's the lack of any guarantee of the invariant that if something is an Entity and HasRoles, then inserting a new version will copy over the existing roles.

One the one hand, if something is an Entity, it doesn't matter if it HasRoles or not. You simply provide the update code, and it should be correct for that specific type.

On the other, this does mean that you'll be reproducing the copyRoles boilerplate for each of your types and you certainly could forget to include it, so it's a legitimate problem.

When you require dynamic dispatch of this nature, one option is to use a GADT to scope over the class context:

class Persisted a where
    update :: a -> a -> IO a

data Entity a where
    EntityWithRoles :: (Persisted a, HasRoles a) => a -> Entity a
    EntityNoRoles   :: (Persisted a) => a -> Entity a

instance Persisted (Entity a) where
    insert (EntityWithRoles orig) (EntityWithRoles newE) = do
      newRoled <- copyRoles orig newE
      EntityWithRoles <$> update orig newRoled
    insert (EntityNoRoles orig) (EntityNoRoles newE) = do
      EntityNoRoles <$> update orig newE

However, given the framework you've described, rather than having an update class method, you could have a save method, with update being a normal function

class Persisted a where
    save :: a -> IO ()

-- data Entity as above

update :: Entity a -> (a -> a) -> IO (Entity a)
update (EntityNoRoles orig) f = let newE = f orig in save newE >> return (EntityNoRoles newE)
update (EntityWithRoles orig) f = do
  newRoled <- copyRoles orig (f orig)
  save newRoled
  return (EntityWithRoles newRoled)

I would expect some variation of this to be much simpler to work with.

A major difference between type classes and OOP classes is that type class methods don't provide any means of code re-use. In order to re-use code, you need to pull the commonalities out of type class methods and into functions, as I did with update in the second example. An alternative, which I used in the first example, is to convert everything into some common type (Entity) and then only work with that type. I expect the second example, with a standalone update function, would be simpler in the long run.

There is another option that may be worth exploring. You could make HasRoles a superclass of Entity and require that all your types have HasRoles instances with dummy functions (e.g. getRoles _ = return []). If most of your entities would have roles anyway, this is actually pretty convenient to work with and it's completely safe, although somewhat inelegant.

0

精彩评论

暂无评论...
验证码 换一张
取 消