1.7 Datatypes🔗ℹ

So far, we have only seen built-in types like Int and Listof(String). Sometimes, it’s useful to define your own name as a shorthand for a type, such as defining Groceries to be equivalent to Listof(String):

type Groceries = Listof(String)

def shopping_list :: Groceries = ["milk", "cookies"]

Note that, by convention, all type names are capitalized. Shplait is case-sensitive.

But what if the data that you need to represent is not easily encoded in existing types, such as when you need to keep track of a tiger’s color and stripe count (which doesn’t work as a list, since a list can’t have a number and a string)? And what if your type, say Animal, has values of different shapes: tigers that have color and stripe counts, plus snakes that have a color, weight, and favorite food?

The type form handles those generalizations when you write cases with |, one for each variant of the type, and where each variant has some number of fields. The general form is

type Type

| variant_name_1(field_name_1 :: Type_1,

                 field_name_2 :: Type_2,

                 ...)

| variant_name_2(field_name_3 :: Type_3,

                 field_name_4 :: Type_4,

                 ...)

| ...

If you’re used to Java-style classes, you can think of Type as an interface, and each variant is a class that implements the interface. Unlike Java classes, a variant name doesn’t work as a type name; it only works to create an instance of the variant.

For example, the following definition is suitable for representing animals that can be either tigers or snakes:

type Animal

| tiger(color :: Symbol,

        stripe_count :: Int)

| snake(color :: Symbol,

        weight :: Int,

        food :: String)

After this definition, Animal can be used as a type, while tiger and snake work as functions to create Animals:

> tiger(#'orange, 12)

- Animal

tiger(#'orange, 12)

> snake(#'green, 10, "rats")

- Animal

snake(#'green, 10, "rats")

The definition of Animal also cooperates with is_a and creates several additional functions:

> def tony = tiger(#'orange, 12)

> def slimey = snake(#'green, 10, "rats")

> tony is_a tiger

- Boolean

#true

> slimey is_a tiger

- Boolean

#false

> tiger.color(tony)

- Symbol

#'orange

> snake.food(slimey)

- String

"rats"

> tiger.color(slimey)

- Symbol

tiger.color: contract violation

  expected: tiger

  given: snake(#’green, 10, "rats")

Note that the type of tiger.color(slimey) printed before an error was reported. That’s because tiger.color(slimey) is well-typed as far as Shplait can tell, since tiger.color wants an Animal and slimey has type Animal.

Using Animal as a type and the is_a tiger and is_a snake predicates, we can write a function that extracts the color of any animal:

fun animal_color(a :: Animal) :: Symbol:

  cond

  | a is_a tiger: tiger.color(a)

  | a is_a snake: snake.color(a)

> animal_color(tony)

- Symbol

#'orange

> animal_color(slimey)

- Symbol

#'green

When writing animal_color, what if we forget the is_a snake case? What if we get snake.color and tiger.color backwards? Unfortunately, the type checker cannot help us detect those problems. If we use match, however, the type checker can help more.

The general form of a match expresison is

match val_expr:

| variant_name_1(field_name_1, field_name_2, ...):

    result_expr_1

| variant_name_2(field_name_3, field_name_4, ...):

    result_expr_2

| ...

The val_expr must produce a value matching the same type as the variant_names, which must all be from the same type. Every variant of the type must be represented by a clause with a matching variant_name, and for that clause, the number of field_names must match the declared number of fields for the variant. The type checker can check all of those requirements.

To produce a value, match determines the variant that is instantiated by the result of val_expr. For the clause matching that variant (by name), match makes each field_name stand for the corresponding field (by position) within the value, and then evaluates the corresponding result_expr.

Here’s animal_color rewritten with match:

fun animal_color(a :: Animal) :: Symbol:

  match a

  | tiger(col, sc): col

  | snake(col, wgt, f): col

> animal_color(tony)

- Symbol

#'orange

> animal_color(slimey)

- Symbol

#'green

Put the definitions of Anmal and animal_color in DrRacket’s definitions area. Then, you can mouse over the variable a in animal_color to confirm that it means the a that is passed as an argument. Mouse over col to see that it means one of the variant-specific fields. Try changing the body of animal_color to leave out a clause or a field variable and see what error is reported when you hit Run.

You should think of match as a pattern-matching form. It matches a value like tiger(#'orange, 12) to the pattern tiger(col, sc) so that col stands for #'orange and sc stands for 12. A value like snake(#'green, 10, "rats") does not match the pattern tiger(col, sc), but it matches the pattern snake(col, wgt, f).

At the end of Lists, we saw a got_milk function that uses cond, similar to the way the dangerous version of animal_color uses cond. The match form works on lists when you use [] and cons(fst, rst) patterns, so here’s an improved got_milk:

fun got_milk(items :: Listof(String)):

  match items

  | []: #false

  | cons(item, rst_items):

      item == "milk" || got_milk(rst_items)

> got_milk([])

- Boolean

#false

> got_milk(["cookies", "milk"])

- Boolean

#true

The [] pattern is a special case in match, and can only be used with cons or ~else in the other case. For types that you define yourself, every pattern in match will have a variant name followed by arguments in parentheses. Even if a variant has no fields, it will have an empty pair of parentheses in its construction and pattern, as demonstrated by the incomplete variant in this example:

type Grade

| letter(alpha :: String)

| pass_fail(is_pass :: Boolean)

| incomplete()

> letter("A")

- Grade

letter("A")

> pass_fail(#true)

- Grade

pass_fail(#true)

> incomplete()

- Grade

incomplete()

fun passed_course(g :: Grade) :: Boolean:

  match g

  | letter(a): a != "F"

  | pass_fail(is_p): is_p

  | incomplete(): #false

> passed_course(letter("B"))

- Boolean

#true

> passed_course(incomplete())

- Boolean

#false

You can use ~else for a final clause in match to catch any variants that are not already covered.

fun is_high_pass(g :: Grade) :: Boolean:

  match g

  | letter(a): a == "A"

  | ~else: #false

> is_high_pass(letter("A"))

- Boolean

#true

> is_high_pass(incomplete())

- Boolean

#false

When you use ~else, however, the type checker is less helpful for making sure that you’ve considered all cases.