Если вы видите что-то необычное, просто сообщите мне. 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создавать willtaskLocation4 generateфункцию, aкоторая taskLocation4будет functionсобираться thatдля willлюбой compileзадачи. forНо anyфункция task.отработает Butправильно, theтолько functionкогда willвызывается onlyComplexTask4. 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функции thetaskLength4. taskLength4Она function.даже Itможет couldиметь eitherтип have type Task -> Int orили Task -> TaskLength.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как thetypedef 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'veString 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отображения typeTask. toКто-то representможет aожидать, Task.что Someoneтип mightTask 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,ADT 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отображения haveTaskLength. 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для fornewtypes newtypesвыглядит looksпохожим a lot like defining anна ADT. However,Однако, anewtype 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и anewtype newtypeидет comesпосле afterкомпиляции yourвашего codeкода. isВ compiled.этом Inпримере, thisне example,будет thereразличий won'tмежду beTaskLength aи differenceInt 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с ournewtype, нежели чем с ADT. Мы можем, например, использовать синтаксическую запись в конструктре для наших newtype. thatЭто weпозволяет canнам doиспользовать withимя ADTs.чтобы Weизвлечь can,значение forизнутри instance,без useсопоставления recordс syntaxобразцом. inЧасто theсопоставление constructorс forобразом ourпри newtype.использовании Thisсинтаксической allowsзаписи usдля toкакого-нибудь useun-TypeName aзначениия nameв toкачестве unwrapимени theполя. insideТак valueже withoutотметим, patternчто matchingмы onне theможем type.использовать Anewtype 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над typeInt. aroundЭто anделает Int.его Thisпохожим makesна itтип seemсиноним, aза lotисключением likeтого, aчто typeмы synonym,не exceptможем thatпросто weиспользовать can'tInt 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касающиеся theTaskLength, mainкомпилятор problemsскажет we'veнам seenо fromTasklength. 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.используем Butnewtype, 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!2.

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!