Unit 8: Stream-based interaction

Introduction

There are several ways of handling interaction in Haskell. The simplest is to use what is known as stream-based interaction. The word stream is just another name for a (potentially) infinite list of characters. In stream-based interaction, the hard work of reading input from a keyboard and writing output to a computer screen is done for you; all you have to do is define a function from String to String, where String is just a list of characters: [Char]. The function you define then transforms the input stream into the output stream. A very simple example is:

cap1 :: String -> String
cap1 = map toUpper

(All the functions discussed here are available in the file unit08.hs.) To run cap1 as an interactive program you just issue the command interact cap1 after entering the Haskell environment and importing or loading the library Data.Char. What you type on the keyboard is passed to the function cap1 as its argument and the value of the function cap1 appears on your computer's screen. Thus, typing The cat produces THE CAT on your screen. This is what happens in some implementations of Haskell. However, under GHCi, if you run interact cap1, then typing The cat produces TThHeE cCaAtT. This is because GHCi's default set-up is that user input is automatically echoed to the screen. It is possible to turn this off. The function interact is not a primitive Haskell function; it is defined as follows using more primitive functions:

interact f = do s <- getContents
                putStr (f s)

It is not necessary to understand this in detail in order to use it. What happens is that the function getContents obtains everything you type on the keyboard and puts it into the string s, then the function putStr writes its argument to your screen; the argument is the result of applying the function f to the argument s. We just need to tweak interact in order to remove the echo it produces. Functions cannot be redefined in Haskell, so it is necessary to define a new function which I call sbi (stream-based interaction):

sbi f  =  do hSetEcho stdin False
             s <- getContents
             putStr (f s)

If you evaluate sbi cap1 under GHCi and type The cat, the characters THE CAT will appear on your computer's screen. There's no sensible way in which to terminate the evaluation of the function sbi cap1; you need to type control-Z to terminate the entire GHCi session. In order to define a function that can be terminated sensibly I need to make use of some functions that will prove useful in defining many interactive functions.

Utility functions for interactive programs

The functions takeWhile and dropWhile are defined in the Haskell Prelude:

takeWhile :: (a -> Bool) -> [a] -> [a]
takeWhile p [] = []
takeWhile p (x:xs)
  | p x       = x:takeWhile p xs
  | otherwise = []

dropWhile :: (a -> Bool) -> [a] -> [a]
dropWhile p [] = []
dropWhile p (x:xs)
  | p x       = dropWhile p xs
  | otherwise = x:xs

The function takeWhile returns the longest initial segment of a list all of whose elements satisfy a given predicate, while the function dropWhile removes the longest initial segment of a list all of whose elements satisfy a given predicate. For example:

takeWhile (<10) [8, 7, 5, 15, 2, 3] = [8, 7, 5]
dropWhile (<10) [8, 7, 5, 15, 2, 3] = [15, 2, 3]

In terms of takeWhile and dropWhile, it is possible to define the functions before and after which are useful in writing interactive programs:

before x = takeWhile (/= x)
after x = tail . dropWhile (/= x)

These behave as follows:

before 'x' "vixen" = "vi"
after 'x' "vixen" = "en"

The function cap2, when run as an interactive functions by getting GHCi to evaluate sbi cap2, capitalises its input, but it terminates if a single ampersand is typed:

cap2 :: String -> String
cap2 = takeWhile (/= '&') . map toUpper

Although cap2 terminates sensibly when an ampersand is typed and returns you to the GHCi prompt, if you then try evaluating sbi cap2 in the same session you'll get an error message informing you that an exception has occurred. To stop this happening issue the system command :set +r at the very start of a session in which you plan to run several interactive functions. (If running an interactive function terminates abnormally, then you will need to reload unit08.hs in order to run another interactive function. This is achieved by issuing the system command :reload which can be shortened to :r.)

Line-based interactive functions

Both the functions cap1 and cap2 process the input supplied to them character by character, but sometimes it is sensible to process input a line at a time. The function cap3, when run as an interactive program by getting GHCi to evaluate sbi cap3, terminates when three ampersands are entered on a line all by themselves. When this happens the three ampersands are not echoed to the screen. All other input is capitalised.

cap3 :: String -> String
cap3 input
  | line == "&&&" = []
  | otherwise     = line ++ "\n" ++ map toUpper line
                         ++ "\n" ++ cap3 input'
      where line   = before '\n' input
            input' = after '\n' input

If you enter any character other than an ampersand, it is immediately echoed to the screen as it is typed. When you type a single ampersand, it is not echoed. This may seem strange, but it is what you would expect because of lazy evaluation. When GHCi encounters any character other than an ampersand, it knows that nothing that follows will cause it to terminate, so it is safe to process that character. However, if an ampersand is entered, what follows may cause the function to terminate. If an ampersand is followed by anything other than an ampersand, then both characters are echoed a soon as the second character is typed.

It is straightforward to modify cap3 so that every character entered is echoed as soon as it is typed. All that we have to is to put the function that tests the input line to see if it consists of three ampersands and nothing else inside a local definition as follows:

cap4 :: String -> String
cap4 input = line ++ "\n" ++ cap4' line input'
  where line   = before '\n' input
        input' = after '\n' input
        cap4' xs ys
          | xs == "&&&" = []
          | otherwise     = map toUpper xs ++ "\n" ++ cap4 ys

All the functions defined so far just capitalise their input in one way or another. The function cap5 cubes numeric input, terminates when three ampersands occur on a line by themselves and capitalises everything else.

allDigs :: String -> Bool
allDigs = and . map isDigit

-- eg, fromString "853" = 853
fromString :: String -> Int
fromString = dec . map digitToInt

-- eg, dec [8, 5, 3] = 853
dec :: [Int] -> Int
dec [i] = i
dec is = 10 * dec (init is) + last is

-- eg, toString 853 = "853"
toString :: Int -> String
toString = map intToDigit . spread

-- eg, spread 853 = [8, 5, 3]
spread :: Int -> [Int]
spread i
  | i < 10  = [i]
  | i >= 10 = spread (div i 10) ++ [mod i 10]

cube :: Int -> Int
cube i = i * i * i

cap5 :: String -> String
cap5 input
  | line == "&&&" = []
  | allDigs line  = line ++ "\n" ++ cube' line
                         ++ "\n" ++ cap5 input'
  | otherwise     = line ++ "\n" ++ map toUpper line
                         ++ "\n" ++ cap5 input'
      where line   = before '\n' input
            input' = after '\n' input
            cube'  = toString . cube . fromString

The echoing behaviour of cap5 may appear strange if you are not familiar with lazy evaluation. Everything starting with a character other than an ampersand or a digit is echoed as it is typed. Any number of consecutive digits are not echoed as they are typed, but once anything other than a numeral is typed everything typed so far is echoed. If the non-digit that is typed is a carriage return, then the number entered is cubed. To get every character echoed as soon as it is typed we need to redefine cap5 as follows:

cap6 :: String -> String
cap6 input = line ++ "\n" ++ cap6' line input'
  where line   = before '\n' input
        input' = after '\n' input
        cap6' xs ys
          | xs == "&&&" = []
          | allDigs xs  = cube' xs ++ "\n" ++ cap6 ys
          | otherwise   = map toUpper xs ++ "\n" ++ cap6 ys
              where cube' = toString . cube . fromString

Comparing cap4 and cap6 you should notice a common pattern of definition. This can be captured by means of the utility function read1.

type StCo = String -> String

read1 :: String -> (String -> StCo) -> StCo
read1 msg g input = msg ++ line ++ "\n" ++ g line input'
  where line   = before '\n' input
        input' = after  '\n' input

read1 msg g is an interactive program which writes the string msg to the screen, then it reads the next line from the keyboard (which ends up in line) and then it behaves like the interactive program g line. Using read1 the function cap6 can be rewritten as follows:

cap7 :: String -> String
cap7 = read1 [] cap7'
  where cap7' xs ys
         | xs == "&&&" = []
         | allDigs xs  = cube' xs ++ "\n" ++ cap7 ys
         | otherwise   = map toUpper xs ++ "\n" ++ cap7 ys
             where cube' = toString . cube . fromString

Table-lookup program

Some utility functions

A more interesting example of interaction in Haskell is provided by a simple table-lookup program. This will also illustrate some of the limitations of stream-based interaction. A table here is just an association list which associates a key with a value, both of which are construed as strings. The program will allow you to enter data into the table and then read it out. In addition to read1 the program makes use of the following utility functions:

read2 :: (String,String) -> ((String,String) -> StCo) -> StCo
read2 (msg1, msg2) g = read1 msg1 g1
  where g1 line1 = read1 msg2 g2
    where g2 line2 = g (line1, line2)

write :: String -> StCo -> StCo
write msg g input = msg ++ g input

end _ = ""

read2 (msg1, msg2) g is an interactive program which writes the string msg1 to the screen, then it reads the next line from the keyboard (which ends up in line1), then it writes the string msg2 to the screen, then it reads the next line from the keyboard (which ends up in line2) and then it behaves like the interactive program g (line1, line2). The function write msg g is an interactive program which writes the string msg to the screen and then it behaves like the interactive program g. The function end is used to terminate an interactive program.

Functions that work on association lists

The table-lookup program makes use of a number of general-purpose functions that operate on association lists. The function enter k v t inserts the tuple (k, v) at the front of the table t. The function present k t tests to see if the key k occurs in the table t. The function delete k t removes all tuples from the table t whose first element is the key k.

enter :: a -> b -> [(a,b)] -> [(a,b)]
enter k v t = (k, v) : t

present :: Eq a => a -> [(a, b)] -> Bool
present k = foldr ((||) . (k ==) . fst) False

delete :: Eq a => a -> [(a, b)] -> [(a, b)]
delete k = filter ((k /=) . fst)

The obvious way to define present is as follows:

present k = or . map (k ==) . map fst

However, because map f . map g is equivalent to map (f . g) and because of fold-map fusion, the previous definition is equivalent to this one and that one is more efficient. (Note that or is defined as foldr (||) False.)

The table-lookup program also makes use of the function lookup (defined in the library Data.List) which, as its name suggests, looks up the value associated with a key in an association list.

lookup :: (Eq a) => a -> [(a,b)] -> Maybe b
lookup _key []  =  Nothing
lookup  key ((x,y):xys)
    | key == x  =  Just y
    | otherwise =  lookup key xys

However, the key given to lookup as its first argument may not be present in the association list which is its second argument. To accommodate this possibility, lookup k t returns values of type Maybe (defined in the library Data.Maybe). If the key is not present in the list, lookup returns the value Nothing. If the key is present, lookup returns Just v, where v is that value The functions isNothing and fromJust are also defined in Data.Maybe. The function isNothing tests to see if its argument is Nothing. The function fromJust extracts the value from an expression of the form Just v. (Type definitions are explained in the next unit.)

data Maybe a = Nothing | Just a
  deriving (Eq, Ord)

isNothing :: Maybe a -> Bool
isNothing Nothing = True
isNothing _       = False

fromJust :: Maybe a -> a
fromJust Nothing  = error "Maybe.fromJust: Nothing"
fromJust (Just x) = x

The main function

The table-lookup program is invoked by means of the command sbi (table newtable). Most of the functions used in the definition of table have already been described. The function tcommand performs a different action depending on what command was issued by the user. The available commands are enter (which enters data into the table), delete (which removes information from the table), lookup (which returns the value associated with a key) and end (which terminates the table-lookup program),

table :: [(String,String)] -> StCo
table t = read1 "Command: " tcommand
  where tcommand "enter"  = read2 ("Key: ", "Value: ") tenter
        tcommand "delete" = read1 "Key: " tdelete
        tcommand "lookup" = read1 "Key: " tlookup
        tcommand "end"    = write "Exit program\n" end
        tcommand _        = write "Unknown command\n" (table t)
        tenter (k, v)
          | present k t   = write ("key in use\n") (table t)
          | otherwise     = write "\n" (table (enter k v t))
        tdelete k         = write "\n" (table (delete k t))
        tlookup k         = write (picked v ++ "\n\n") (table t)
          where v         = lookup k t
                picked w = if (isNothing w)
                           then "No entry"
                           else (fromJust w)

newtable = []

Sample session

*Main> sbi (table newtable)
Command: lookup
Key: year
No entry

Command: enter
Key: year
Value: 89

Command: enter
Key: rate
Value: 35

Command: lookup
Key: year
89

Command: end
Exit program
*Main> 

Limitations

One of the main limitations of stream-based interaction in Haskell is that it doesn't have any file-handling capabilities. In the table-lookup program, any information you input is lost once you exit the program; it is not possible to write the information you've input to a file. It is possible to handle files from within a Haskell program, but that requires the use of the IO type and various functions associated with it.

Further reading

More information about stream-based interaction can be found in Richard Bird's book Introduction to Functional Programming using Haskell (1998), section 9.6, pp. 319–322. Much more information can be found in Bird and Wadler, Introduction to Functional Programming (1988), section 7.8, pp. 196–203, from where I have obtained the utility functions before, after, read1, read2, write and end; the table-lookup program used here is a modified version of theirs.

© Antoni Diller (22 September 2021)