Если вы видите что-то необычное, просто сообщите мне. Skip to main content

Делая свой тип.

Вновь, добро пожаловать на серию Отрыв Понедельнечного Хаскельного Утра! Заключительная часть. На случай, если вы прпоустили 2 прошлые главы. В первой части мы обсудили базовую установку Haskell платформы. Затем окунулись в написание базовых выражений на Haskell в интерпретаторе. Во второй части, мы начали с написания нашей собственной функции в модуле Haskell. Так же изучили всяких синтаксических уловок для построения больших и улучшенных функций.

В третьей части мы собираемся углубиться в системы типов. Изучим как создавать свои типы данных, а так же хитрости для упрощения описания наших типов.

Созание нового типа данных

Вперед, к типам данных! Помните, что у нас есть github репозиторий где вы можете получить код для этой части. Если вы хотите реализовать его самостоятельно, вы можете перейти к модулю DataTypes. Но если вы просто хотите посмотреть на завершенный код, вы можете взглянут на DataTypesComplete.

Для этой статьи, предскавим. что мы пытаемся смоделировать некий TODO список. В этой статье создадим несколько различных Task типов данных для отражения отдельных задач в списке. Создадим тип данных сначала у которого будет ключевое слово и затем имя типа. Затем добавим оператор присваивания =.

module DataTypes where

data Task1 = ...

В отличии от выражения и функции именя которые мы использовали в ранее, наши типы начинаются с заглавной буквы. Это то что отличает типы от обычных выражений в Haskell. Теперь собираемся создать наш первый конструктор. Это специальный тип выражения, который позволяет нам создавать объект нашего типа Task. Они имеют схожесть с конструкторами скажем на Java. Но они они так же очень сложны. Конструкторы имеют Заглавные буквы а так же список типов. Этот список типов содержит информацию которую хранит конструктор. В нашем случае, мы хотим, чтобы наша задача имела имя и ожидаемоее время выполнения в минутах, отражены как String, и Int соответственно.

data Task1 = BasicTask1 String Int

Вот так, теперь мы можем начать создавать Task объекты. Например, давайте определим пару простых задач как выражения в нашем модуле.

assignment1 :: Task1
assignment1 = BasicTask1 "Do assignment 1" 60

laundry1 :: Task1
laundry1 = BasicTask1 "Do Laundry" 45

Мы можем загрузить наш код в интерпретатор, чтоы проверить что он собирается и имеет смысл:

>> :l MyData.hs
>> :t assignment1
assignment1 :: Task1
>> :t laundry1 
laundry1 :: Task

Отметим, что тип нашего выражения Task1 даже не смотря, что мы собираемся объекты используя BasicTask1Constructor. В Java, можно иметь множество конструкторов для одного типа. Мы можем сделать так же и в Haskell, но выглядит это по сложнее. Давайте определим другой тип для различных мест, где мы можем работать над задачами. Мы можем производить работу над задачами в школе, офисе, дома. Отразим это создава конструктор для каждого из них. Разделим конструктор используя вертикальную черту |:

data Location =
  School |
  Office |
  Home

В этом случае, каждый из конструкторов простая отметка, которая не имеет параметров или данных хранящихся в нем. Это пример Enum типа. Мы можем технически сделать различные типы выражения отражающими каждый из них.

schoolLocation :: Location
schoolLocation = School

officeLocation :: Location
officeLocation = Office

homeLocation :: Location
homeLocation = Home

Но эти выражения не более полезны чем использовать сами конструкторы.

Теперь, имея пару типов, мы можем сделать так, что один из наших типов будет содержать другие! Добавим новый конструктор в наш тип задач. Это будет еще сложнее чем просто список мест.

data Task1 =
  BasicTask1 String Int |
  ComplexTask1 String Int Location
...

complexTask :: Task1
complexTask = ComplexTask1 "Write Memo" 30 Office

Это сильно отличается от конструктора в других языках. Мы можем иметь различные поля для различных отображений типов. Можно обернуть совершенно отличающийся тип зависящий от конструктора который мы используем. Это отлично, так как дает нам гибкость, которую ругие языке не могут.

Параметризированные типы

Еще использовать параметризированные типы с другими определениями типов. Это значит, что один или более полей зависят от типа, который был выбран человеком который писал код. Давайте предположим, у нас есть тип, который имеет несколько базовых конструкторов для различных видов времени. Это ограничит наше описание для простоты.

data TaskLength =
  QuarterHour |
  HalfHour |
  ThreeQuarterHour |
  Hour |
  HourAndHalf |
  TwoHours |
  ThreeHours

Теперь мы хотим описать задачу где время задачи будет выражатся в Int. Но так же хотим, чтобы была возможность описать с помощью нового типа. Давайте сделаем вторую верси нашего Task типа, который может использовать оба типа для времени выполнения. Мы можем сделать это с помощью параметризованного типа:

data Task2 a =
  BasicTask2 String a |
  ComplexTask2 String a Location

Тип стал мистическим, и теперь мы можем его заполнять как хотим. Но теперь при выводе Task2 типа в сигнатуре, мы должны будет заполнить правильное определение.

assignment2 :: Task2 Int
assignment2 = BasicTask2 "Do assignment 2" 60

assignment2' :: Task2 TaskLength
assignment2' = BasicTask2 "Do assignment 2" Hour

laundry2 :: Task2 Int
laundry2 = BasicTask2 "Do Laundry" 45

laundry2' :: Task2 TaskLength
laundry2' = BasicTask "Do Laundry" ThreeQuarterHour

complexTask2 :: Task2 TaskLength
complexTask2 = ComplexTask2 "Write Memo" HalfHour Office

К этом нужно относится с осторожностью, так как это может ограничить нашу возможность делать определнные вещи. Например, мы не можем создать список, который содержит оба и assignment2 и complexTask2. Это потому, что два выражения теперь различные типы.

-- THIS WILL CAUSE A COMPILER ERROR
badTaskList :: [Task2 a]
badTaskList = [assignment2, complexTask2]

Пример списка

Говоря о списках, мы можем приоткрыть завесу тайны о том, как списки реализованны.

Большое количество синтаксического сахара меняют способ написания списка на практике. Но на уровне кода, списки определяются двумя конструкторами, Nil и Cons.

data List a =
  Nil |
  Cons a (List a)

Как мы ожидаем, тип List имеет один параметр. Это то что позволяет нам одновременно иметь Int или String. Конструктор Nil это пустой список. Не содержит объектов. Поэтому в любое время, в которое вы будете использовать выражение [], занайте вы используете Nil. Второй конструктор складывает один элемент с другим списком. Тип элемента и списка должны, конечно же совпадать. При использовании : оператора для добавления элемента в список, вы уже используете Cons конструктор.

emptyList :: [Int]
emptyList = [] -- Actually Nil

fullList :: [Int]
-- Equivalent to Cons 1 (Cons 2 (Cons 3 Nil))
-- More commonly written as [1,2,3]
fullList = 1 : 2 : 3 : []

Еще одна вещь, то что наша структура данных рекурсивна. Мы можем увидеть в Cons конструкторе как список содержит другой список с параметрами. Это Работает отлично, покоа есть какой-то базовый случай! Тогда, у нас будет Nil. Представьте если у нас есть один конструктор и он принимает рекурсивный параметр. У нас возникает затруднительное положение, из-за того, что мы не знаем как создать любойс писок на первом месте.

Синтаксическая записи

Давайте вернемся к основам, непараметризированному типу данных Task. Предположим, нас не волнует в целом объект Task. Скорее, мы хотим один из его кусочков, напиример имя или время. Так как наш код - единственный способ сделать это использовать сопоставление с образцом который явит нужное поле.

import Data.Char (toUpper)

...

twiceLength :: Task1 -> Int
twiceLength (BasicTask1 name time) = 2 * time

capitalizedName :: Task1 -> String
capitalizedName (BasicTask1 name time) = map toUpper name

tripleTaskLength :: Task1 -> Task1
tripleTaskLength (BasicTask1 name time) = BasicTask1 name (3 * time)

Теперь слегка упрощаем. Вы можете использовать нижнее подчеркивание вместо параметра, который вы не хотите исопльзовать. Но несмотря на это, может получится громоздко если у ваш тип имеет ножество полей. Мы можем написать нашу функцию позволяющую иметь доступ к отдельным полям. Под капотом, конечно же, будет сопоставление с образом.

taskName :: Task1 -> String
taskName (BasicTask1 name _) = name

taskLength :: Task1 -> Int
taskLength (BasicTask1 _ time) = time

twiceLength :: Task1 -> Int
twiceLength task = 2 * (taskLength task)

capitalizedName :: Task1 -> String
capitalizedName task = map toUpper (taskName task)

tripleTaskLength :: Task1 -> Task1
tripleTaskLength task = BasicTask1 (taskName task) (3 * (taskLength task))

Но это применение нельзя масштабировать, так как нам нужно писать эту функцию для каждого поля, которое мы будем создавать. Теперь представьте насколько легко, использовать метод setter в Java. Сравним это с tripleTaskLength выше. Нужно протись по всем полям, что не есть хорошо. Отличная новость, в том, что мы можем заставить Haskell написать функцию для нас использовать синтаксис записи. Для этого, всё, что нам нужно это назначить каждому полю в определении нашего типа. Давайте сделаем новую версию Task.

data Task3 = BasicTask3
  { taskName :: String
  , taskLength :: Int }

Теперь можно писать тот же код без getter функции которую мы писали выше.

-- These will now work WITHOUT our separate definitions for "taskName" and 
-- "taskLength"
twiceLength :: Task3 -> Int
twiceLength task = 2 * (taskLength task)

capitalizedName :: Task3 -> String
capitalizedName task = map toUpper (taskName task)

Теперь можно создать задачу, мы всё еще можем использвать BasicTask3 сам по себе. Но для чистоты кода, мы можем так же создать объект используя синтаксическую запись, где мы называли поле:

-- BasicTask3 "Do assignment 3" 60 would also work
assignment3 :: Task3
assignment3 = BasicTask3 
  { taskName = "Do assignment 3" 
  , taskLength = 60 }

laundry3 :: Task3
laundry3 = BasicTask3 
  { taskName = "Do Laundry"
  , taskLength = 45 }

Мы так же можем написать setter еще проще используя синтаксическую запись. Вопользуемся прошлой задачей и затем списоком изменений "changes" чтобы поместить их в скобки.

tripleTaskLength :: Task3 -> Task3
tripleTaskLength task = task { taskLength = 3 * (taskLength task) }

В общем, мы используем только синтаксическую запись, когда есть один конструктор для типа данных. Мы можем использовать различные поля для различных конструкторов, но только наш код чуток безопаснее. Давайте посмотрим на еще один пример определения Task:

data Task4 = 
  BasicTask4
    { taskName4 :: String,
      taskLength4 :: Int }
  |
  ComplexTask4 
    { taskName4 :: String,
      taskLength4 :: Int,
      taskLocation4 :: Location }

The trouble with this system is that the compiler will generate a taskLocation4 function that will compile for any task. But the function will only be valid when called on a ComplexTask4. So the following code will compile, even though it will cause a crash, and we want to avoid that:

causeError :: Location
causeError = taskLocation4 (BasicTask4 "Cause error" 10)

In addition, if our different constructors use different types, we can't use the same name for them. This can be frustrating when we want to represent the same concept with different types. This example won't compile because GHC cannot determine the type of the taskLength4 function. It could either have type Task -> Int or Task -> TaskLength.

data Task4 = 
  BasicTask4
    { taskName4 :: String,
      taskLength4 :: Int }
  |
  ComplexTask4 
    { taskName4 :: String,
      taskLength4 :: TaskLength, -- Note we use "TaskLength" and not an Int here!
      taskLocation4 :: Location }

The Type Keyword

Now we know most of the ins and outs of making our own data types. But there are times when you don't need to do this. We can create new type names without making a completely new data structure. There are two ways to do this. The first is the type keyword. It allows you to create a synonym for a type, like the typedef keyword in C++. The most common of these, as we've seen, is that a String is actually a list of characters:

type String = [Char]

A common use case for this is when you've combined many different types together in a tuple. It can be quite tedious to write this tuple down several times in your code:

makeTupleBigger :: (Int, String, Task) -> (Int, String, Task)
makeTupleBigger (intValue, stringValue, (BasicTask name time) = 
  (2 * intValue, map toUpper stringValue, (BasicTask (map toUpper name) (2 * time)))

A type synonym would make the signature here look a lot cleaner:

type TaskTuple = (Int, String, Task)

makeTupleBigger :: TaskTuple -> TaskTuple
makeTupleBigger (intValue, stringValue, (BasicTask name length) = 
  (2 * intValue, map toUpper stringValue, (BasicTask (map toUpper name) (2 * length))

Of course, if this collection of items shows up a lot, it might be worth making a full data type for it. There are also some reasons why type synonyms aren't always the best choice. For one thing, they can lead to compile errors that can be difficult to work through. You've probably come across a few errors already where the compiler told you it expected a [Char]. It would have been far more clear if it had said String.

It can also lead to some unintuitive code. Suppose you use a basic tuple instead of a data type to represent a Task. Someone might expect your Task type to be its own data type. Then they'll be a little confused when you manipulate it like a tuple:

type Task5 = (String, Int)

twiceTaskLength :: Task5 -> Int
-- "snd task" is confusing here
twiceTaskLength task = 2 * (snd task)

Newtypes

The last topic we'll cover is "newtypes". These are like type synonyms in some ways, and ADTs in other ways. But they still have a unique place in Haskell and it is good to get accustomed to using them. Let's suppose we want to have a new approach to representing TaskLength. We want to use a regular number, but we want it to have its own separate type. We can do this using "newtype":

newtype TaskLength2 = TaskLength2 Int

The syntax for newtypes looks a lot like defining an ADT. However, a newtype definition can only have a single constructor. And that constructor can only take a single type argument. The big difference between an ADT and a newtype comes after your code is compiled. In this example, there won't be a difference between the TaskLength and Int types at runtime. This is good because a lot of code for Int types is specialized to run fast. If we were to make this a true ADT, this would not be the case:

-- Not as fast!
data TaskLength2 = TaskLength2 Int

But otherwise, we can do a lot of the same tricks with our newtype that we can do with ADTs. We can, for instance, use record syntax in the constructor for our newtype. This allows us to use a name to unwrap the inside value without pattern matching on the type. A frequent pattern when using record syntax is to use something like "un-TypeName" value as the field name. Also note that we can't use the newtype value with the same functions as the original type. When we had type synonyms, we could do this, but it won't here:

data Task6 = BasicTask6 String TaskLength2

newtype TaskLength2 = TaskLength2
  { unTaskLength :: Int }

mkTask :: String -> Int -> Task6
mkTask name time = BasicTask6 name (TaskLength2 time)

twiceLength :: Task6 -> Int
twiceLength (BasicTask6 _ len) = 2 * (unTaskLength len)
-- The following would be WRONG!
-- 2 *len

Now, TaskLength2 is effectively a wrapper type around an Int. This makes it seem a lot like a type synonym, except that we can't simply use the Int value itself. As you can see in the examples above, we do have to go through the process of wrapping and unwrapping the value. This seems tedious. But it is quite useful because it solves the main problems we've seen from using type synonyms. Now if we make a mistake involving TaskLength, the compiler will tell us it's about TaskLength. We won't be wondering if there's a synonym we're missing!

Here's another example. Suppose we have a function with several integral arguments. If we always use Int types, we can easily confuse the order of the arguments. But when we use a newtype, the compiler will catch this type of error for us.

Conclusion

This wraps up our discussion on creating your own data types and is the conclusion of our Liftoff Series! If you need a refresher, don't forget to check out part 1 and part 2 to refresh yourself on the basics. For some more resources on learning Haskell, download our free Beginner's Checklist! You'll be able to review all the concepts you learned in this series. The checklist will also tell you about some tools that will streamline your Haskell workflow!

If you want to take the next step in your Haskell education, you should check out our Stack Mini-Course. This short video course walk you through how to use Stack and the Haskell platform to start making your own Haskell project!