Is it bad form to make new types/datas for clarity? [closed]
Asked Answered
L

5

16

I would like to know if it is bad form to do something like this:

data Alignment = LeftAl | CenterAl | RightAl
type Delimiter = Char
type Width     = Int

setW :: Width -> Alignment -> Delimiter -> String -> String

Rather than something like this:

setW :: Int -> Char -> Char -> String -> String

I do know that remaking those types effectively does nothing but take up a few lines in exchange for clearer code. However, if I use the type Delimiter for multiple functions, this would be much clearer to someone importing this module, or reading the code later.

I am relatively new to Haskell so I do not know what is good practice for this type of stuff. If this is not a good idea, or there is something that would improve clarity that is preferred, what would that be?

Laburnum answered 16/7, 2019 at 16:48 Comment(3)
No -- rather, I would consider the former a good form. Your type Alignment makes it clear that there are only three valid values, and gives nice names to those. By comparison Char is much more vague, and allows invalid values. Aliases like Delimiter and Width are less useful, but nice to have, especially if there are many functions using them.Twila
A delimeter is what you use to measure meat at the supermarket. A delimiter is a boundary between segments of something.Garrek
Another possible solution to make the code clearer would be to use as argument a record with named fields (this isn't mutually exclusive with the use of newtypes). The decision to use a record depends on how closely related the arguments are, if we don't mind losing partial application, if we want to provide a "default" record argument, and so on.Ruderal
H
19

You're using type aliases, they only slightly help with code readability. However, it's better to use newtype instead of type for better type-safety. Like this:

data Alignment = LeftAl | CenterAl | RightAl
newtype Delimiter = Delimiter { unDelimiter :: Char }
newtype Width     = Width { unWidth :: Int }

setW :: Width -> Alignment -> Delimiter -> String -> String

You will deal with extra wrapping and unwrapping of newtype. But the code will be more robust against further refactorings. This style guide suggests to use type only for specializing polymorphic types.

Hochheimer answered 16/7, 2019 at 17:12 Comment(10)
Worth pointing out that calls to unDelimiter and unWidth are "zero-cost", in that they are only necessary for type checking; there is no actual call at run-time.Satiable
Might also be worth mentioning when a type alias is worth using, which I think is for giving a shorter name to something like a monad stack (type App = ReaderT SomeEnvironment IO (), e.g.).Satiable
I'm no expert in lenses but I'm pretty sure the Lens library uses type aliases as wellLeukas
pipes is another example: there's one basic data type, data Proxy a' a b' b m r, and everything thing else is just an alias that hides the use of concrete types as arguments to Proxy. (E.g., type Producer b = Proxy X () () b.) (On the other hand, pipes also defines type X = Void for reasons that aren't really clear.)Satiable
Adding that for Width, you can add deriving(Num) to allow to still do arithmetic on it without any clunky boxing and unboxingGusti
@Satiable X exists, at least in part, for historical reasons. Years ago, Data.Void wasn't in base, so depending on the void package was necessary for using Void. At some point. pipes elminated the dependency by rolling its own. When Data.Void made it into base, X became a synonym.Ethelda
Note that Delimeter is a spelling error. It should be Delimiter. This is going to cause someone some serious head banging debugging it.Languid
Type synonyms are all over lens, which uses them to do certain sorts of magic that newtypes would interfere with (though I wonder if QuantifiedConstraints can work around that now). They're also more generally useful with higher-rank types. Type synonyms also show up in heavily typish code as degenerate type families. type Reverse xs = ReverseOnto '[] xs; type family ReverseOnto acc xs where .... And with ConstraintKinds, constraint synonyms are especially useful as one-offs to declare a class and its sole instance when there are many constraints.Ostracod
That said, my general view is that beginners shouldn't use type synonyms.Ostracod
@chepner, for a monad transformer stack, it's usually best to declare a newtype, enable GeneralizedNewtypeDeriving to get the instances you need, and define functions with more domain-appropriate names. For example, just because you're using ReaderT Blob doesn't mean you need MonadReader Blob. Just define getBlob = T ask.Ostracod
C
14

I wouldn't consider that bad form, but clearly, I don't speak for the Haskell community at large. The language feature exists, as far as I can tell, for that particular purpose: to make the code easier to read.

One can find examples of the use of type aliases in various 'core' libraries. For example, the Read class defines this method:

readList :: ReadS [a]

The ReadS type is just a type alias

type ReadS a = String -> [(a, String)]

Another example is the Forest type in Data.Tree:

type Forest a = [Tree a]

As Shersh points out, you can also wrap new types in newtype declarations. That's often useful if you need to somehow constrain the original type in some way (e.g. with smart constructors) or if you want to add functionality to a type without creating orphan instances (a typical example is to define QuickCheck Arbitrary instances to types that don't otherwise come with such an instance).

Cheesewood answered 16/7, 2019 at 17:17 Comment(3)
I second your opinion. Even though typedefs don't give any extra typechecker guarantees that a newtype would, they are a useful means of quickly sorting out your signatures. This is especially useful in the prototyping stage – quickly get together the types you need (as well as some you may in the end not really need) without having to write any boilerplate code. But because it's already a distinguishable name, you can easily change the definition to a proper newtype later on an the compiler will make it easy to adapt the code.Tillford
We have, I believe, removed Forest from the type signatures for all exported functions. Why? It's confusing! You'd expect fmap :: (a -> b) -> Forest a -> Forest b, but that's not the case! Rather, fmap :: (Tree a -> Tree b) -> Forest a -> Forest b. Just terrible. ReadS is awful for the same reason: people expect parsers to have Functor, Applicative, and Monad instances that behave in certain ways, and ReadS looks like a parser type, but its instances are all (->) String. Terribly confusing!Ostracod
Er.... Actually not... I thought we had... I meant to ... I must not have merged something.....Ostracod
R
10

Using newtype—which creates a new type with the same representation as the underlying type but not substitutable with it— is considered good form. It's a cheap way to avoid primitive obsession, and it's especially useful for Haskell because in Haskell the names of function arguments are not visible in the signature.

Newtypes can also be a place on which to hang useful typeclass instances.

Given that newtypes are ubiquitous in Haskell, over time the language has gained some tools and idioms to manipulate them:

  • Coercible A "magical" typeclass that simplifies conversions between newtypes and their underlying types, when the newtype constructor is in scope. Often useful to avoid boilerplate in function implementations.

    ghci> coerce (Sum (5::Int)) :: Int

    ghci> coerce [Sum (5::Int)] :: [Int]

    ghci> coerce ((+) :: Int -> Int -> Int) :: Identity Int -> Identity Int -> Identity Int

  • ala. An idiom (implemented in various packages) that simplifies the selection of a newtype that we might want to use with functions like foldMap.

    ala Sum foldMap [1,2,3,4 :: Int] :: Int

  • GeneralizedNewtypeDeriving. An extension for auto-deriving instances for your newtype based on instances available in the underlying type.

  • DerivingVia A more general extension, for auto-deriving instances for your newtype based on instances available in some other newtype with the same underlying type.

Ruderal answered 16/7, 2019 at 17:57 Comment(1)
Using coerce directly is crude since inference requires guidance in the extreme, but very powerful. Ideally you never have to use it directly but we're not there yet. Great answer!Outstay
E
6

One important thing to note is that Alignment versus Char is not just a matter of clarity, but one of correctness. Your Alignment type expresses the fact that there are only three valid alignments, as opposed to however many inhabitants Char has. By using it, you avoid trouble with invalid values and operations, and also enable GHC to informatively tell you about incomplete pattern matches if warnings are turned on.

As for the synonyms, opinions vary. Personally, I feel type synonyms for small types like Int can increase cognitive load, by making you track different names for what is rigorously the same thing. That said, leftaroundabout makes a great point in that this kind of synonym can be useful in the early stages of prototyping a solution, when you don't necessarily want to worry about the details of the concrete representation you are going to adopt for your domain objects.

(It is worth mentioning that the remarks here about type largely don't apply to newtype. The use cases are different, though: while type merely introduces a different name for the same thing, newtype introduces a different thing by fiat. That can be a surprisingly powerful move -- see danidiaz's answer for further discussion.)

Ethelda answered 16/7, 2019 at 23:45 Comment(0)
H
2

Definitely is good, and here is another example, supose you have this data type with some op:

data Form = Square Int | Rectangle Int Int | EqTriangle Int

perimeter :: Form -> Int
perimeter (Square s)      = s * 4
perimeter (Rectangle b h) = (b * h) * 2
perimeter (EqTriangle s)  = s * 3

area :: Form -> Int
area (Square s)      = s ^ 2
area (Rectangle b h) = (b * h)
area (EqTriangle s)  = (s ^ 2) `div` 2 

Now imagine you add the circle:

data Form = Square Int | Rectangle Int Int | EqTriangle Int | Cicle Int

add its operations:

perimeter (Cicle r )      = pi * 2 * r

area (Cicle r)       = pi * r ^ 2

it is not very good right? Now I want to use Float... I have to change every Int for Float

data Form = Square Double | Rectangle Double Double | EqTriangle Double | Cicle Double


area :: Form -> Double

perimeter :: Form -> Double

but, what if, for clarity and even for reuse, I use type?

data Form = Square Side | Rectangle Side Side | EqTriangle Side | Cicle Radius

type Distance = Int
type Side = Distance
type Radius = Distance
type Area = Distance

perimeter :: Form -> Distance
perimeter (Square s)      = s * 4
perimeter (Rectangle b h) = (b * h) * 2
perimeter (EqTriangle s)  = s * 3
perimeter (Cicle r )      = pi * 2 * r

area :: Form -> Area
area (Square s)      = s * s
area (Rectangle b h) = (b * h)
area (EqTriangle s)  = (s * 2) / 2
area (Cicle r)       = pi * r * r

That allows me to change the type only changing one line in the code, supose I want the Distance to be in Int, I will only change that

perimeter :: Form -> Distance
...

totalDistance :: [Form] -> Distance
totalDistance = foldr (\x rs -> perimeter x + rs) 0

I want the Distance to be in Float, so I just change:

type Distance = Float

If I want to change it to Int, I have to make some adjustments in the functions, but thats other issue.

Homosexual answered 17/7, 2019 at 14:27 Comment(1)
Good example. Though 1. I'd argue that Distance should never have been Int in the first place, nor Float for that matter, but Double – but the point is, which you're making very well here, that a typedef makes it easy to change this implementation detail. 2. It would actually make sense to make a stronger type distinction here, i.e. with newtypes rather than typedefs. Note that Distance is not really a number type: multiplying two distances gives an Area, and adding a distance to an area doesn't make sense.Tillford

© 2022 - 2024 — McMap. All rights reserved.