Nonempty strings

Thanks for sticking with us, and congratulations on having your first working Haskell program! From here, we’ll be working on improvements to our palindrome program.

Room for improvement

There are ways in which our palindrome test is not quite satisfactory, even though we have our basic functionality working. For example, at the moment it considers empty strings to be palindromes, even though that’s probably not exactly what we want. Run main and just hit enter without typing any input:

λ> main

True

It also won’t count a word as a palindrome if it has any capital letters that don’t match up, which might run contrary to our usual expectations. We know that “mom” is a palindrome, but it is not recognized if we capitalize it. Computers don’t know that M and m are the same thing – and in some sense, maybe they are right, but we would prefer to consider "Mom" as a palindrome.

λ> main
Mom
False

And, as we hinted earlier, separating “tacocat” into two words also throws it off.

λ> main
taco cat
False

So we want to invent some ways to handle these things. We’ll deal with each of these potential improvements separately, and then eventually figure out ways to bring them all together.

Catching the empty string

Let’s start by thinking through what we want to do about empty strings. We will acknowledge at the outset that it would be weird of you, personally, as the only user of your program to enter an empty string! However, you’re doing an I/O here – anything could go wrong and you must be prepared for all contingencies!

We could fall back to our trusty conditional statement. If the input is equal to an empty string, for example, then we could return False, otherwise we would apply the isPalindrome function to the input. This means that this function will take a String input and return a Bool output:

  • False if the input is empty;
  • True or False depending on if the non-empty input a palindrome.
nonemptyPal :: String -> Bool
nonemptyPal word =
    if (word == "") then False else (isPalindrome word)

We could alter our main to use this instead of using isPalindrome directly, too. Since isPalindrome and nonemptyPal have the same type, all we have to do is replace isPalindrome with nonemptyPal in the definition of main:

main :: IO ()
main =
  do
    word <- getLine
    print (nonemptyPal word)

Load this into the REPL and test it. We can see that it does work as expected.

λ> main

False
λ> main
Julie
False

But we might find it a little unsatisfying that an empty string gives us the same result as a non-empty string that isn’t a palindrome. It seems less than ideal that the output does not distinguish between these two situations. There is still something we can improve upon.

Perhaps, perhaps, perhaps

Earlier we mentioned that there’s a type we sometimes use for cases like this where you want to discriminate a real result from a rejection of invalid input. Like Bool, this type has two cases – but rather than False and True, the cases are nothing and something. We call this type Maybe.

Remember, Maybe has two cases:

  • Nothing, which often indicates that no valid input was received;
  • Just <something>.

In our present circumstance, we could use Nothing to indicate that there was an empty string input, and use the Just constructor to return the Boolean result of applying isPalindrome to the nonempty string. This gives us a way to discriminate the cases where we want to say “this isn’t valid input” and “this isn’t a palindrome,” which seems useful.

To do this, let’s go ahead and change the nonemptyPal function. This time, we have to change the type signature: it is now returning not just a Bool but a Maybe Bool – the True or False result might not be present!

nonemptyPal :: String -> Maybe Bool

If you don’t like to delete things, you can write a new function instead of modifying nonemptyPal, but keep in mind that your two functions will need to have different names. Or you may want to comment out the old function, by placing two hyphens before each line. This will allow you to keep the old code around for reference.

-- nonemptyPal :: String -> Bool
-- nonemptyPal word =
--     if (word == "") then False else (isPalindrome word)

nonemptyPal :: String -> Maybe Bool

Now what we’ll do is that pattern-matching that we introduced earlier. This time, though, we’ll be matching on the cases of the list type. We recall that the list type (like Maybe and Bool) has two cases, the empty list and the nonempty list. What we care about right now is matching the empty list to a Nothing result.

nonemptyPal :: String -> Maybe Bool
nonemptyPal word =
  case word of
      "" -> Nothing

You could use the square list brackets there or the double quotation marks that are special for strings, it’s up to you; Haskell knows they both indicate lists so it doesn’t care which you use here.

So now what do we do for the other case? When we were matching on Bool it was pretty clear what the two cases were, but when we were matching on Integers in our very first function, we used an underscore to catch all the other possible inputs. And that will work for us here, too.

In every case except the empty string that we’ve already covered, we want to apply the isPalindrome to the word input, and then we want to apply the Just constructor to that output. Fortunately, we can do that on the right side of a case arrow.

nonemptyPal :: String -> Maybe Bool
nonemptyPal word =
  case word of
      "" -> Nothing
      _  -> Just (isPalindrome word)

Remember the main

But we’ve changed the type of nonemptyPal – it now outputs a Maybe Bool instead of merely a Bool. Does main still work?

We need to know if Maybe Bool works with the print function, so it needs to have an instance of the Show class. We can find this information in GHCi again, by querying for info about Maybe.

λ> :info Maybe
data Maybe a = Nothing | Just a         -- Defined in ‘GHC.Maybe’
instance Applicative Maybe -- Defined in ‘GHC.Base’
instance Eq a => Eq (Maybe a) -- Defined in ‘GHC.Maybe’
instance Functor Maybe -- Defined in ‘GHC.Base’
instance Monad Maybe -- Defined in ‘GHC.Base’
instance Semigroup a => Monoid (Maybe a) -- Defined in ‘GHC.Base’
instance Ord a => Ord (Maybe a) -- Defined in ‘GHC.Maybe’
instance Semigroup a => Semigroup (Maybe a)
  -- Defined in ‘GHC.Base’
instance Show a => Show (Maybe a) -- Defined in ‘GHC.Show’
instance MonadFail Maybe -- Defined in ‘Control.Monad.Fail’
instance Read a => Read (Maybe a) -- Defined in ‘GHC.Read’
instance Foldable Maybe -- Defined in ‘Data.Foldable’
instance Traversable Maybe -- Defined in ‘Data.Traversable’

Finding an entry for Show in that list gives us the answer to that question: Yes, there is a Show instance for Maybe.

instance Show a => Show (Maybe a)

So we don’t need to change anything in main, thanks to the power of polymorphism.

We do need to save and reload into the REPL (or click run in repl.it, perhaps). Try it on a series of inputs, at least one empty string, one palindrome, and one word that is not a palindrome to give yourself confidence that it works and you understand why it works.

Exercise

Try to write a similar function, using this case syntax. Call the function verbose and give it the type String to String.

verbose :: String -> String

It’s going to take the word input (ultimately, this word will come from getLine, remember!), apply the nonemptyPal to it, and match the three possible outputs of nonemptyPal to strings that tell the user some information about the result. For example, “Yay, this is a palindrome!”, “Please enter a word.”, or “Sorry, this is not a palindrome.”

Join Type Classes for courses and projects to get you started and make you an expert in FP with Haskell.