3
\$\begingroup\$

I'm currently learning Haskell, and as one of my first "proper" programs have created a mathematics quiz. It works like this:

  • A question is generated, with two numbers from 1 to 10 which need to be either added, multiplied or subtracted, for example 3 * 6.
  • The question is presented to the user, and they can write their answer and press Enter.
  • If the user's answer is the same as the actual answer to the question, they get one point towards their score. If the answer is incorrect their points stay the same.
  • This repeats 10 times, and the user's points are shown to them at the end.

I've implemented the operator part of question generation using an Operator data type with a Random instance. The numbers are generated with a randomSensibleInts x gen function, which generates x random numbers between 1 and 10.

The askQuestion function uses IO to get the user's answer, returning True if the answer was correct and False otherwise. Finally, in main, the askQuestion function is called 10 times, returning a [Bool]. fromEnum is then used to count True as one point and False as zero, which combined with sum creates a point total.

My code is as follows:

import System.Random

randomSensibleInts :: Int -> StdGen -> [Int]
randomSensibleInts quantity = take quantity . randomRs (1, 10)

data Operator = Add | Subtract | Multiply deriving (Eq, Enum)

instance Show Operator where
  show op = case op of
             Add -> "+"
             Subtract -> "-"
             Multiply -> "*"

instance Random Operator where
  random gen = case randomR (0, 2) gen of
                 (item, gen') -> (toEnum item, gen')

  randomR (lo, hi) gen = case randomR (fromEnum lo, fromEnum hi) gen of
                           (item, gen') -> (toEnum item, gen')

genQuestion :: StdGen -> (Int, Operator, Int, StdGen)
genQuestion gen = (numbers !! 0, operator, numbers !! 1, gen')
                  where numbers = randomSensibleInts 2 gen 
                        (operator, gen') = random gen :: (Operator, StdGen)

askQuestion :: (Int, Operator, Int, StdGen) -> IO Bool
askQuestion (i1, o, i2, gen) = do
  putStrLn $ unwords ["Enter the answer to", show i1, show o, show i2] ++ ": "
  userAnswer <- getLine
  let trueAnswer = case o of
                     Add -> i1 + i2
                     Subtract -> i1 - i2
                     Multiply -> i1 * i2
  if (show trueAnswer) == userAnswer then do
    putStrLn "Well done!"
    return True
  else do
    putStrLn $ "Wrong - the answer was " ++ show trueAnswer
    return False

main = do
  gens <- mapM (const newStdGen) [1..10]
  let questions = map genQuestion gens
  answers <- mapM askQuestion questions
  putStrLn $ "Your score was " ++ show (sum $ map fromEnum answers)

Please let me know if my code can be made more "Haskell-like", for example if there are builtins to replace any of my custom functions. I'm also interested in pointfree style, so please let me know if any of my functions can be improved to use this. I'm also fairly certain this code can be shortened drastically.

\$\endgroup\$

2 Answers 2

2
\$\begingroup\$

The Show typeclass is for debugging

The Show typeclass is really meant to be derived, not implemented yourself. The purpose of Show is mostly for debugging, and the closest thing to a law for Show is that it should be the inverse of Read. The result of show should usually be a string that represents a Haskell expression that would produce the value itself.

While it is tempting to create a custom instance of Show for your own purposes, I would just convert your instance into a separate function, showOperator (which can also be implemented more simply with direct pattern matching rather that explicitly using case):

showOperator :: Operator -> String
showOperator Add = "+"
showOperator Subtract = "-"
showOperator Multiply = "-"

randomSensibleInts is poorly-named

What makes integers in the range [1, 10] any more “sensible” than any other integers? Given how incredibly simple it is, and given it’s only used in one place, I would honestly just inline it into the place it’s used. I think it’s clearer that way than any name you could give it.

Avoid !!

The !! function is unsafe, since it will crash if the provided index is out of bounds. Admittedly, in your case, you know the length of the list will always be exactly 2, so it’s not dangerous, but in that case, you should just use pattern-matching instead:

genQuestion :: StdGen -> (Int, Operator, Int, StdGen)
genQuestion gen = (x, operator, y, gen')
  where [x, y] = take 2 $ randomRs (1, 10) gen
        (operator, gen') = random gen

(You also don’t need the type annotation on random gen, so I’ve removed it.)

Package tuples into named datatypes

The long tuple return type for genQuestion is a little bit suspicious. The name even provides a good suggestion for naming the datatype: Question. Create a datatype for that instead of having such a large tuple:

data Question = Question Int Operator Int

genQuestion :: StdGen -> (Question, StdGen)
genQuestion gen = (Question x operator y, gen')
  where ...

askQuestion :: (Question, StdGen) -> IO Bool
askQuestion (Question i1 o i2, gen) = ...

Avoid using booleans directly

Using Bool as a return type of a non-predicate function can be confusing (this is referred to as “boolean blindness”). Using a separate, named type can be both more readable and more scalable if the number of cases ever changes:

data AnswerResult = Correct | Incorrect
  deriving (Eq)

askQuestion :: (Question, StdGen) -> IO AnswerResult
askQuestion = ...

This also allows adjusting the score summing logic to be a bit more reliable. Using toEnum and assigning significance to the indices is both unnecessary and mixing concerns.

putStrLn $ "Your score was " ++ show (length $ filter (== Correct) answers)

Avoid mixing pure and impure logic

The askQuestion function has too many responsibilities: formats a prompt for input, displays it to the user, calculates the answer to the question, validates the answer to the question, and prints out a response based on the answer. Three of those responsibilities are totally pure, and they should ideally be pulled into their own functions:

questionPrompt :: Question -> String
questionPrompt (Question x op y) = "Enter the answer to " ++ show x ++ showOperator op ++ show y ++ ": "

questionAnswer :: Question -> Int
questionAnswer (Question x op y) = operatorFunction op x y
  where operatorFunction Add = (+)
        operatorFunction Subtract = (-)
        operatorFunction Multiply = (*)

validateAnswer :: Question -> Int -> AnswerResult
validateAnswer question answer =
  if questionAnswer question == answer
  then Correct
  else Incorrect

askQuestion :: (Question, StdGen) -> IO AnswerResult
askQuestion (question, gen) = do
  putStrLn $ questionPrompt question
  userAnswer <- getLine
  let answerResult = validateAnswer question (read userAnswer)
  case answerResult of
    Correct -> putStrLn "Well done!"
    Incorrect -> putStrLn $ "Wrong - the answer was " ++ show (questionAnswer question)
  return answerResult

Eliminate unused variables

If you turn on -Wall, GHC will tell you that gen is never used in askQuestion at all, so it can be removed:

genQuestion :: StdGen -> Question
genQuestion gen = Question x operator y
  where [x, y] = take 2 $ randomRs (1, 10) gen
        (operator, _) = random gen

askQuestion :: Question -> IO AnswerResult
askQuestion question = do
  putStrLn $ questionPrompt question
  userAnswer <- getLine
  let answerResult = validateAnswer question (read userAnswer)
  case answerResult of
    Correct -> putStrLn "Well done!"
    Incorrect -> putStrLn $ "Wrong - the answer was " ++ show (questionAnswer question)
  return answerResult

main :: IO ()
main = do
  gens <- mapM (const newStdGen) [1..10]
  let questions = map genQuestion gens
  answers <- mapM askQuestion questions
  putStrLn $ "Your score was " ++ show (length $ filter (== Correct) answers)

Use where or let instead of case when there is only one pattern

In the Random instance for Operator, it’s possible to avoid case if you’re simply destructuring rather than matching against multiple patterns:

instance Random Operator where
  random gen = (toEnum item, gen')
    where (item, gen') = randomR (0, 2) gen

  randomR (lo, hi) gen = (toEnum item, gen')
    where (item, gen') = randomR (fromEnum lo, fromEnum hi) gen

Final result

import System.Random

data Operator = Add | Subtract | Multiply
  deriving (Eq, Enum)

data Question = Question Int Operator Int

data AnswerResult = Correct | Incorrect
  deriving (Eq)

instance Random Operator where
  random gen = (toEnum item, gen')
    where (item, gen') = randomR (0, 2) gen

  randomR (lo, hi) gen = (toEnum item, gen')
    where (item, gen') = randomR (fromEnum lo, fromEnum hi) gen

showOperator :: Operator -> String
showOperator Add = "+"
showOperator Subtract = "-"
showOperator Multiply = "-"

questionPrompt :: Question -> String
questionPrompt (Question x op y) = "Enter the answer to " ++ show x ++ showOperator op ++ show y ++ ": "

questionAnswer :: Question -> Int
questionAnswer (Question x op y) = operatorFunction op x y
  where operatorFunction Add = (+)
        operatorFunction Subtract = (-)
        operatorFunction Multiply = (*)

validateAnswer :: Question -> Int -> AnswerResult
validateAnswer question answer =
  if questionAnswer question == answer
  then Correct
  else Incorrect

genQuestion :: StdGen -> Question
genQuestion gen = Question x operator y
  where [x, y] = take 2 $ randomRs (1, 10) gen
        (operator, _) = random gen

askQuestion :: Question -> IO AnswerResult
askQuestion question = do
  putStrLn $ questionPrompt question
  userAnswer <- getLine
  let answerResult = validateAnswer question (read userAnswer)
  case answerResult of
    Correct -> putStrLn "Well done!"
    Incorrect -> putStrLn $ "Wrong - the answer was " ++ show (questionAnswer question)
  return answerResult

main :: IO ()
main = do
  gens <- mapM (const newStdGen) [1..10]
  let questions = map genQuestion gens
  answers <- mapM askQuestion questions
  putStrLn $ "Your score was " ++ show (length $ filter (== Correct) answers)
\$\endgroup\$
0
1
\$\begingroup\$
import Control.Monad.Random
import Control.Monad

main = do
  results <- replicateM 10 $ do
    (operator, opstring) <- uniform [((+), "+"), ((-), "-"), ((*), "*")]
    [i1, i2] <- replicateM 2 $ randomR (1, 10)
    putStrLn $ unwords ["Enter the answer to", show i1, opstring, show i2] ++ ": "
    let trueanswer = operator i1 i2
    correct <- fmap (trueanswer ==) readLn
    putStrLn $ if correct
      then "Well done!"
      else "Wrong - the answer was " ++ show trueAnswer
    return correct
  putStrLn $ "Your score was " ++ show (length $ filter id results)

Show is usually used to generate Haskell code that can recover the shown value.

I wouldn't name stuff that's only used once and doesn't deserve to be a library function.

\$\endgroup\$
6
  • \$\begingroup\$ Judging by a couple of our answers, I think we have completely opposing views on Haskell style. ;) \$\endgroup\$ Commented Aug 18, 2016 at 21:00
  • \$\begingroup\$ Yes, even your prose is longer than needed :P \$\endgroup\$
    – Gurkenglas
    Commented Aug 18, 2016 at 21:02
  • \$\begingroup\$ To be completely honest, in real code I would probably write something somewhere in the middle, but I err on the side of small, single-responsibility functions for simple things. Your answer is nice and concise, though. :) \$\endgroup\$ Commented Aug 18, 2016 at 21:04
  • \$\begingroup\$ Wow - Haskell can be very short! The other answer has more explanation though. \$\endgroup\$ Commented Aug 19, 2016 at 5:33
  • \$\begingroup\$ What's that uniform function doing? Is it picking randomly? \$\endgroup\$ Commented Aug 19, 2016 at 5:39

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.