matt
matt

Reputation: 2039

Haskell: Reducing Boilerplate

What is the accepted way to reduce the amount of repetition in code like this?

newtype Fahrenheit = Fahrenheit Double deriving (Eq)
newtype Celsius    = Celsius   Double deriving (Eq)
newtype Kelvin     = Kelvin    Double deriving (Eq)
newtype Rankine    = Rankine   Double deriving (Eq)
newtype Reaumure   = Reaumure  Double deriving (Eq)
newtype Romer      = Romer     Double deriving (Eq)
newtype Delisle    = Delisle   Double deriving (Eq)
newtype Newton     = Newton    Double deriving (Eq)

instance Show Fahrenheit where
  show (Fahrenheit f) = show f ++ " °F"

instance Show Celsius where
  show (Celsius c) = show c ++ " °C"

instance Show Kelvin where
  show (Kelvin k) = show k ++ " K"

instance Show Rankine where
  show (Rankine r) = show r ++ " °R"

instance Show Reaumure where
  show (Reaumure r) = show r ++ " °Ré"

instance Show Romer  where
  show (Romer  r) = show r ++ " °Rø"

instance Show Delisle where
  show (Delisle d) = show d ++ " °De"

instance Show Newton where
  show (Newton n) = show n ++ " N°"

class Temperature a where
  increaseTemp  :: a -> Double -> a
  decreaseTemp  :: a -> Double -> a
  toFahrenheit  :: a -> Fahrenheit
  toCelsius     :: a -> Celsius
  toKelvin      :: a -> Kelvin
  toRankine     :: a -> Rankine
  toReaumure    :: a -> Reaumure
  toRomer       :: a -> Romer 
  toDelisle     :: a -> Delisle
  toNewton      :: a -> Newton

instance Temperature Fahrenheit where
  increaseTemp (Fahrenheit f) n = if n < 0 then error "negative val" else Fahrenheit $ f + n
  decreaseTemp (Fahrenheit f) n = if n < 0 then error "negative val" else Fahrenheit $ f - n
  toFahrenheit                  = id
  toCelsius    (Fahrenheit f)   = Celsius  $ (f - 32) * 5 / 9
  toKelvin     (Fahrenheit f)   = Kelvin   $ (f - 32) * 5 / 9 + 273.15
  toRankine    (Fahrenheit f)   = Rankine  $ f + 458.67
  toReaumure   (Fahrenheit f)   = Reaumure $ (f - 32) * 4 / 9
  toRomer      (Fahrenheit f)   = Romer    $ (f - 32) * 7 / 24 + 7.5
  toDelisle    (Fahrenheit f)   = Delisle  $ (212 - f) * 5 / 6
  toNewton     (Fahrenheit f)   = Newton   $ (f - 32) * 11 / 60

instance Temperature Celsius where
  increaseTemp (Celsius c) n = if n < 0 then error "negative val" else Celsius   $ c + n
  decreaseTemp (Celsius c) n = if n < 0 then error "negative val" else Celsius   $ c - n
  toFahrenheit  (Celsius c)   = Fahrenheit $ c * 9 / 5 + 32
  toCelsius                  = id
  toKelvin     (Celsius c)   = Kelvin    $ c + 273.15
  toRankine    (Celsius c)   = Rankine   $ c * 9/5 + 491.67
  toReaumure   (Celsius c)   = Reaumure  $ c * 4 / 5
  toRomer      (Celsius c)   = Romer     $ c * 21 / 40 + 7.5
  toDelisle    (Celsius c)   = Delisle   $ (100 - c) * 3 / 2
  toNewton     (Celsius c)   = Newton    $ c * 33 / 100

instance Temperature Kelvin where
  increaseTemp (Kelvin k) n = if n < 0 then error "negative val" else Kelvin    $ k + n
  decreaseTemp (Kelvin k) n = if n < 0 then error "negative val" else Kelvin    $ k - n
  toFahrenheit  (Kelvin k)   = Fahrenheit $ (k - 273.15)  *  9 / 5 + 32
  toCelsius    (Kelvin k)   = Celsius   $ k - 273.15
  toKelvin                  = id
  toRankine    (Kelvin k)   = Rankine   $ k * 9 / 5
  toReaumure   (Kelvin k)   = Reaumure  $ (k - 273.15) * 4 / 5
  toRomer      (Kelvin k)   = Romer     $ (k - 273.15) * 21 / 40 + 7.5
  toDelisle    (Kelvin k)   = Delisle   $ (373.15 - k) * 3 / 2
  toNewton     (Kelvin k)   = Newton    $ (k - 273.15) * 33 / 100

-- rest of the instances omitted.

also, in the Class definition is there a way of restricting the type of in the input variable to one of the Units. ie toCelsius :: a -> Celsius, is there anything that can be done about constraining what that a can be? or is that implied by the fact that it will only work on Types that have instances declared.

Upvotes: 2

Views: 182

Answers (2)

user11228628
user11228628

Reputation: 1526

This is an adaptation of @Cubic's excellent answer, but: you don't need fancy data kinds to do this.

{-# LANGUAGE ScopedTypeVariables, TypeApplications #-}

import Data.Proxy

newtype Temperature u = Temperature Double deriving Eq

class TemperatureUnit u where
  label :: Proxy u -> String
  toKelvin :: Temperature u -> Double
  fromKelvin :: Double -> Temperature u

instance TemperatureUnit u => Show (Temperature u) where
  show (Temperature t) = show t ++ " " ++ label (Proxy @u)

convertTemperature :: forall u1 u2. (TemperatureUnit u1, TemperatureUnit u2) => Temperature u1 -> Temperature u2
convertTemperature = fromKelvin . toKelvin

data Fahrenheit
data Celsius
data Kelvin

instance TemperatureUnit Fahrenheit where
  label _ = "°F"
  toKelvin (Temperature t) = (t - 32) * 5/9 + 273.15
  fromKelvin k = Temperature $ (k - 273.15) * 9/5 + 32

instance TemperatureUnit Celsius where
  label _ = "°C"
  toKelvin (Temperature t) = t + 273.15
  fromKelvin k = Temperature $ k - 273.15

instance TemperatureUnit Kelvin where
  label _ = "K"
  toKelvin (Temperature t) = t
  fromKelvin k = Temperature k

Try it online!


The advantage of the data kinds approach in this case, as far as I know, is that if you need that TemperatureUnit data type for some other purpose, you can reuse it instead of also defining data Fahrenheit etc. types as I did here. It also constrains the possible temperature types to what you define in the TemperatureUnit type, which could be good or bad for you. It's good that you get the extra type checking that you can't have a TemperatureUnit Bool, for example, but that sort of mistake would very likely be caught by the compiler elsewhere, albeit perhaps with a less clear error. And if you're exporting this functionality, you may want an open world of temperature types so that downstream modules can add their own.

So if you don't already have a TemperatureUnit type in use elsewhere, IMO not using data kinds is simpler and more flexible.

Upvotes: 4

Cubic
Cubic

Reputation: 15673

Main problem seems to be the unit conversions, which you can make significantly shorter and less boilerplat-y using DataKinds and a bunch of other scary looking language extensions (only for 3 units, but you should be able to generalise this easily enough):

{-# LANGUAGE DataKinds,
             KindSignatures,
             RankNTypes,
             ScopedTypeVariables,
             AllowAmbiguousTypes,
             TypeApplications #-}
data TemperatureUnit = Fahrenheit | Celsius | Kelvin
newtype Temperature (u :: TemperatureUnit) = Temperature Double deriving Eq

class Unit (u :: TemperatureUnit) where
  unit :: TemperatureUnit

instance Unit Fahrenheit where unit = Fahrenheit
instance Unit Celsius where unit = Celsius
instance Unit Kelvin where unit = Kelvin

instance Show TemperatureUnit where
  show Celsius = "°C"
  show Fahrenheit = "°F"
  show Kelvin = "K"

instance forall u. Unit u => Show (Temperature u) where
  show (Temperature t) = show t ++ " " ++ show (unit @u)

convertTemperature :: forall u1 u2. (Unit u1, Unit u2) => Temperature u1 -> Temperature u2
convertTemperature (Temperature t) = Temperature . fromKelvin (unit @u2) $ toKelvin (unit @u1) where
  toKelvin Celsius = t + 273.15
  toKelvin Kelvin = t
  toKelvin Fahrenheit = (t - 32) * 5/9 + 273.15
  fromKelvin Celsius k = k - 273.15
  fromKelvin Kelvin k = k
  fromKelvin Fahrenheit k = (k - 273.15) * 9/5 + 32

You can then use it like this:

-- the explicit type signatures here are only there to resolve
-- ambiguities; In more realistic code you'd not need them as often
main = do
  let (t1 :: Temperature Celsius) = Temperature 10.0
      (t2 :: Temperature Fahrenheit) = Temperature 10.0
  putStrLn $ show t1 ++ " = " ++ show (convertTemperature t1 :: Temperature Fahrenheit)
  -- => 10.0 °C = 50.0 °F
  putStrLn $ show t2 ++ " = " ++ show (convertTemperature t2 :: Temperature Celsius)
  -- => 10.0 °F = -12.222222222222221 °C

Try it online!

The trick here is that DataKinds allows us to lift regular data types to the kind level, and their data constructor to the the type level (which I understand aren't really different things in modern versions of GHC anymore? Sorry, I'm a bit shaky on the subject myself). We then just define a helper class to get the data version of the unit back so we can dispatch based on it. This allows us the sort of thing you tried to do with all your newtype wrappers, except with less newtype wrappers (and less instance declarations, and less named functions overall).

The other thing of course is that you still have a combinatorial explosion between different unit conversions - you can either suck it up and write all n^2 formulas for those by hand, or you can try to generalise it (may be possible for temperature units as per @chepner's comment, but I'm not sure it's possible for all sorts of things you could want to convert between). This approach can't solve that inherent problem, but it does remove some of the syntactic noise you incur with the newtype-per-unit approach.

Your increaseTemp and decreaseTemp functions could be implemented as a single function offsetTemperature while allowing negative numbers. Although I think it'd make more sense to have them take a temperature with the same unit as the second parameter rather than just a Double:

offsetTemperature :: Temperature u -> Temperature u -> Temperature u
offsetTemperature (Temperature t) (Temperature offset) = Temperature (t + offset)

PS: Temperature should probably not be an instance of Eq - floating point equality is notoriously wonky (predictable, but probably doesn't do what you want). I only kept it in here because it was in your example.

Upvotes: 5

Related Questions