Главная / Курсы / Haskell / Кортеж

Глава 11. Кортеж

В этой главе мы познакомимся с кортежем и ещё ближе подружимся с паттерн матчингом.

Кортеж

Кортеж (англ. tuple) — стандартная структура данных Haskell. В отличие от списка, она может содержать данные как одного типа, так и разных.

Структуры, способные содержать данные разных типов, называют гетерогенными (в переводе с греческого: «разного рода»).

Вот как выглядит кортеж:

("Haskell", 2010)

Круглые скобки и значения, разделённые запятыми. Этот кортеж содержит значение типа String и ещё одно, типа Int. Вот ещё пример:

("Haskell", "2010", "Standard")

То есть ничто не мешает нам хранить в кортеже данные одного типа.

Тип кортежа

Тип списка строк, как вы помните, [String]. И не важно, сколько строк мы запихнули в список, одну или миллион — его тип останется неизменным. С кортежем же дело обстоит абсолютно иначе.

Тип кортежа зависит от количества его элементов. Вот тип кортежа, содержащего две строки:

(String, String)

Вот ещё пример:

(Double, Double, Int)

И ещё:

(Bool, Double, Int, String)

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

-- Разные типы
(String, String)
(String, String, String)

Действия над кортежами

Со списками можно делать много всего, а вот с кортежами — не очень. Самые частые действия — собственно формирование кортежа и извлечение хранящихся в нём данных. Например:

makeAlias :: String -> String -> (String, String)
makeAlias host alias = (host, alias)

Пожалуй, ничего проще придумать нельзя: на входе два аргумента, на выходе — двухэлементный кортеж с этими аргументами. Двухэлементный кортеж называют ещё парой (англ. pair). И хотя кортеж может содержать сколько угодно элементов, на практике именно пары встречаются чаще всего.

Обратите внимание, насколько легко создаётся кортеж. Причина тому — уже знакомый нам паттерн матчинг.

Мы просто указываем соответствие между левой и правой сторонами определения: «Пусть первый элемент пары будет равен аргументу host, а второй — аргументу alias». Ничего удобнее и проще и придумать нельзя.

Переделайте функцию makeAlias из примера выше: пусть она возвращает кортеж не из двух, а из трех элементов: хоста, URL и имени (alias).

Для получения URL конкатенируйте строку "https://" и имя.

module Main where

-- Your code here

main :: IO ()
main = print (makeAlias "173.194.71.106" "www.google.com")

Оператор ++ — это оператор конкатенации, склеивающий две строки в одну. Строго говоря, он склеивает два списка, но мы-то с вами уже знаем, что String есть ни что иное, как [Char]. Таким образом, "https://" ++ "www.google.com" даёт нам "https://www.google.com".

module Main where makeAlias :: String -> String -> (String, String, String) makeAlias host alias = (host, "https://" ++ alias, alias) main :: IO () main = print (makeAlias "173.194.71.106" "www.google.com")
Задача # 1

Извлечение элементов из кортежа также производится через паттерн матчинг:

makeAlias :: String -> String -> (String, String)
makeAlias host alias = (host, alias)

main :: IO ()
main =
let (host, alias) = makeAlias "173.194.71.106"
"www.google.com"
in print (host ++ ", " ++ alias)

Функция makeAlias даёт нам пару из хоста и имени. Но что это за странная запись возле уже знакомого нам слова let? Это промежуточное выражение, но выражение хитрое, образованное через паттерн матчинг. Чтобы было понятнее, сначала перепишем функцию без него:

main :: IO ()
main =
let pair = makeAlias "173.194.71.106"
"www.google.com"
host = fst pair -- Берём первое...
alias = snd pair -- Берём второе...
in print (host ++ ", " ++ alias)

При запуске этой программы получим:

"173.194.71.106, www.google.com"

Стандартные функции fst и snd возвращают первый и второй элемент кортежа соответственно. Работают они только с кортежем из двух элементов, то есть с парой.

Выражение pair здесь соответствует паре, выражение host — значению хоста, а alias — значению имени. Но не кажется ли вам такой способ избыточным? Мы в Haskell любим изящные решения, поэтому предпочитаем паттерн матчинг.

Раскроем подробнее вышеприведённый способ:

let (host, alias) = makeAlias "173.194.71.106" "www.google.com"

let (host, alias) = ("173.194.71.106", "www.google.com")

Читается это так: host присвоить значение "173.194.71.106"; alias присвоить значение "www.google.com". Вот такая простая магия. Функция makeAlias даёт нам пару, и мы достоверно знаем это! А если знаем, нам не нужно вводить промежуточные выражения вроде pair. Мы сразу говорим, что точно знаем: выражение, вычисленное функцией makeAlias — это пара значений.

let (host, alias) = makeAlias "173.194.71.106" "www.google.com"

Это «зеркальная» модель. Через паттерн матчинг формируем:

-- Формируем правую сторону
-- на основе левой...
host alias = (host, alias)

и через него же извлекаем:

-- Формируем левую сторону
-- на основе правой...
(host, alias) = ("173.194.71.106", "www.google.com")

Элементы списка можно получать по индексу с помощью оператора !!. А что насчет кортежей? С ними такой фокус не сработает! Чтобы получить элемент кортежа, применяйте паттерн матчинг. Либо функции fst и snd для пар. Есть и третий способ. О нем — в конце главы.

Что выведет этот код? В случае ошибки напишите error.

module Main where

chessMove :: String
-> (String, String)
-> (String, (String, String))
chessMove color (from, to) = (color, (from, to))

main :: IO ()
main = print (color ++ ": " ++ from ++ "-" ++ to)
where
(color, (from, to)) = chessMove "white" ("e2", "e4")

Функция chessMove даёт нам кортеж с кортежем, а раз мы точно знаем вид этого кортежа, сразу указываем where-выражение в виде образца: (color, (from, to)) = chessMove "white" ("e2", "e4"). Функция print применится к конкатенации строк "white", ": ", "e2", "-", "e4".

white: e2-e4
Задача # 2

Обратите внимание, объявление функции в задаче отформатировано чуток иначе: типы выстроены друг под другом через выравнивание стрелок под двоеточием. Вы часто встретите такой стиль в Haskell-проектах.

Напишите функцию formatLocation, которая:

  • Принимает кортеж из двух значений типа Double: широты и долготы.
  • Возвращает строку вида "You are here: LAT, LON", где LAT и LON — значения широты и долготы. Для превращения Double в строку воспользуйтесь встроенной функцией show.

В функции main используйте конструкцию where, чтобы вывести на экран результат применения функции к кортежу из чисел 34.7 и 10.1.

module Main where

-- Your code here

main :: IO ()
main = -- And here

Определите функцию formatLocation, принимающую кортеж и возвращающую строку. В блоке where функции main заведите кортеж: loc = (34.7, 10.1).

module Main where formatLocation :: (Double, Double) -> String formatLocation (lat, lon) = "You are here: " ++ show lat ++ ", " ++ show lon main :: IO () main = print (formatLocation loc) where loc = (34.7, 10.1)
Задача # 3

Кортежи и универсальные образцы

Мы можем вытаскивать по образцу лишь часть нужной нам информации. Помните универсальный образец _?

Взгляните. Функция patientEmail даёт нам почту пациента.

-- Поясняющие псевдонимы
type UUID = String
type FullName = String
type Email = String
type Age = Int
type Patient = (UUID, FullName, Email, Age)

patientEmail :: Patient -> Email
patientEmail (_, _, email, _) = email

main :: IO ()
main =
putStrLn (patientEmail ( "63ab89d"
, "John Smith"
, "johnsm@gmail.com"
, 59
))

Тип Patient — это псевдоним для кортежа из четырёх элементов: уникальный идентификатор, полное имя, адрес почты и возраст. Дополнительные псевдонимы делают наш код яснее: одно дело видеть безликую String и совсем другое — Email.

Рассмотрим внутренность функции patientEmail:

patientEmail (_, _, email, _) = email

Функция говорит нам: «Да, я знаю, что мой аргумент — это четырёхэлементный кортеж, но меня в нём интересует исключительно третий по счёту элемент, соответствующий адресу почты, его я и верну».

Универсальный образец _ делает наш код лаконичнее и понятнее, ведь он помогает нам игнорировать то, что нам неинтересно. Строго говоря, мы не обязаны использовать _, но с ним будет лучше.

Напишите функцию extractTimestamp, которая принимает кортеж из 3-х элементов: широты, долготы и временной метки. Функция должна вернуть значение временной метки.

Заведите псевдонимы и используйте их в объявлении функции: Latitude, Longitude, UnixTimestamp.

module Main where

-- Your code here

main :: IO ()
main = print (extractTimestamp (37.0932, 51.8821, 1715536019))

Требуется получить третий элемент кортежа: (_, _, ts) = ts.

module Main where type Latitude = Double type Longitude = Double type UnixTimestamp = Int extractTimestamp :: (Latitude, Longitude, UnixTimestamp) -> UnixTimestamp extractTimestamp (_, _, ts) = ts main :: IO () main = print (extractTimestamp (37.0932, 51.8821, 1715536019))
Задача # 4

А если ошиблись?

При использовании паттерн матчинга в отношении пары следует быть внимательным. Представим себе, что вышеупомянутый тип Patient был расширен:

type UUID = String
type FullName = String
type Email = String
type Age = Int
type DiseaseId = Int -- Новый элемент.
type Patient = ( UUID
, FullName
, Email
, Age
, DiseaseId
)

Был добавлен идентификатор заболевания. И всё бы хорошо, но внести изменения в функцию patientEmail мы забыли:

patientEmail :: Patient -> Email
patientEmail (_, _, email, _) = email

В кортеже (_, _, email, _) отсутствует пятый элемент! К счастью, в этом случае компилятор строго обратит наше внимание на ошибку:

Couldn't match type ‘(t0, t1, String, t2)’
               with ‘(UUID, FullName, Email, Age, DiseaseId)’
Expected type: Patient
  Actual type: (t0, t1, String, t2)
In the pattern: (_, _, email, _)

Оно и понятно: функция patientEmail использует образец, который уже некорректен. Вот почему при использовании паттерн матчинга следует быть внимательным.

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

Для любопытных

Для работы с элементами многоэлементных кортежей можно использовать готовые библиотеки, во избежание длинных паттерн матчинговых цепочек. Например, пакет tuple. Он не входит в стандартную библиотеку и устанавливается отдельно.

Для того чтобы в своем проекте использовать сторонний пакет, его нужно указать в качестве зависимости (секция dependencies конфига package.yaml), после чего собрать проект через stack build. Подробнее об этом будет в главе про Hackage и библиотеки.

И вот как выглядит импорт модуля Data.Tuple.Select из пакета tuple:

import Data.Tuple.Select

main :: IO ()
main = print (sel4 (123, 7, "hydra", "DC:4", 44, "12.04"))

Функция sel4 из модуля Data.Tuple.Select извлекает четвёртый по счёту элемент кортежа, в данном случае строку "DC:4". Там есть функции вплоть до sel32, авторы вполне разумно сочли, что никто, находясь в здравом уме и твёрдой памяти, не станет оперировать кортежами, состоящими из более чем 32 элементов.

Кроме того, мы и обновлять элементы кортежа можем:

import Data.Tuple.Update

main :: IO ()
main = print (upd2 2 ("si", 45))
("si",2)

Второй элемент кортежа изменился с 45 на 2.

Для обновления кортежа в пакете tuple есть модуль Data.Tuple.Update. Он содержит функции вида updN val tpl, где N — номер элемента кортежа (начиная с 1), val — новое значение для элемента, tpl — сам кортеж.

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

Подключите этот модуль. Затем допишите определение функции f, которая принимает целое число и кортеж. Она заменяет второй элемент кортежа на это число.

module Main where

f :: Int -> (String, Int) -> (String, Int)
-- Your code here

main :: IO ()
main = print (f 3 ("si", 45))

Примените функцию upd2 к значению типа Int и кортежу (String, Int).

module Main where import Data.Tuple.Update f :: Int -> (String, Int) -> (String, Int) f newVal tpl = upd2 newVal tpl main :: IO () main = print (f 3 ("si", 45))
Задача # 5

Отправка...
Наша группа в telegram. Здесь можно задавать вопросы и общаться.
Задонатить. Если вам нравится курс, вы можете поддержать развитие площадки!