Reputation: 2039
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
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
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
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
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