Hacker News new | ask | show | jobs
by sparkie 4357 days ago
I'll give that I didn't give thought to what would happen if you later added u, but on the other hand, I'd consider that changing Y (if you expose it from the module) is a breaking change in any circumstance. If we consider for example, a Haskell equivalent with pattern matching.

    data Foo = X { u :: Int, v :: Int }
             | Y { v :: Int }

    bar :: Foo -> ...
    bar (X u v) = ...
    bar (Y v) = ...
If "u" were then added to Y, this pattern matching would break - the names of the fields don't matter in this case - the arity does. We just can't add to Y here without going through every pattern match on Foo to fix it.

The only case where changing Y is not a "breaking change" is if Y is never exported from the module - one of Haskell's most underused features is the ability to hide constructors and expose custom functions in their place - such functions would easily allow adding to Y (but not change the function y) without breaking the public interface - we need a default value for u though.

    module Foo (Foo, x, y) where

    data Foo = X Int Int
             | Y Int Int

    x :: Int -> Int -> Foo
    x = X

    y :: Int -> Foo
    y = Y defaultU

    defaultU = 0

    u :: Foo -> Maybe Int
    u (X a _) = Just a
    u _ = Nothing

    v :: Foo -> Int
    v (X _ b) = b
    v (Y b) = b
Just manually expanding what records do, except you have more control over changes to it - if you expect changes, you probably want to do this. The caveat is that you can no longer pattern match over X/Y from outside the module - unless you expose "isX, isY" functions, and use -XViewPatterns or some other extension. I actually prefer manually expanding things out this way because I consider Haskell's record system to be so poor.

It's not just a poor record system, but also the idea of encapsulation. To me, exposing constructors is like making the guts of your classes public in an OOP language - allowing any outsider to access fields really leads to tightly coupled code, and makes it difficult to introduce changes like the one you've suggested.

Consider a trivially modified list class where we want to optimise the performance of `length` if it were used frequently. I could define a data type for it with an extra "Int" field to hold the length.

    data LList a = Nil | Cons Int a (LList a)
What would I expose from this module? Almost certainly not the constructors, because I wouldn't want a consumer arbitrarily inserting integers, nor should they need to include it in their pattern matches - what I really want is to expose exactly the same "Cons" and "length" functions as the regular list which doesn't contain the head index.

    cons :: a -> LList a -> LList a
    cons a Nil = Cons 1 a Nil
    cons a (Cons n h t) = Cons (n + 1) a (Cons h t) 
    
    length :: LList a -> Int
    length Nil = 0
    length (Cons n _ _) = n
This is really what we want to expose, the constructors themselves don't matter - exposing constructors is only even useful in the cases where the structure of the type maps exactly to the operations we want to perform on it from outside the module, and thus we don't need to "hide" anything. It's not all that common, more often than not you want to hide things (OOP wasn't created by accident). Most haskell programmers think like C programmers - in terms of data rather than the operations you perform on the data.

If we continue this OOP analogy to the record syntax, we're treating Foo as some object exposing its public fields and some constructor functions.

    public class Foo {
        public Int u;
        public Int v;

        private Foo(Int u, Int v) {
            this.u = u;
            this.v = v;
        }
        
        private Foo(Int v) {
            this.v = v;
        }

        public static Foo X(Int u, Int v) { 
            return new Foo(u, v); 
        }

        public static Foo Y(Int v) {
            return new Foo(v);
        }
    }
Now, if we construct Foo.Y(1), and attempt to access `u`, we get the dreaded "NullReferenceException". In Haskell and Mars we've simply renamed `null` to "Runtime error", but it's effectively the same thing.

To me it's a design flaw. Optional obviates the need for null when used properly. I'm not sure what would be the best way to go about it - whether my previous suggestion, or making all fields Maybe, restricting records to types with only one constructor, or just do away with records altogether, because they're ultimately unnecessary. Writing out your own constructor functions gives you more flexibility, and having record syntax is not all that useful compared to say, using lenses.

1 comments

To me, automatic Maybe inference seems correct. I don't think the concerns about types being broken (when one makes `u` total) are valid. That's really the point of a strong type system. You get the errors at compile time and they show you that the semantics of your program are inconsistent. My only issue with it is that it's a little too "magical". I'd rather that the behavior of record functions be more explicit. That makes lenses seem more attractive, as you mentioned.