Pull to refresh

Элементарные типы и операции над ними. Часть I: типы данных, размер, ограничение.

Reading time 15 min
Views 14K
Строительными кирпичиками любого языка является элементарные типы данных с которыми мы можем работать. Зная их, мы всегда понимаем, что у нас хранится в той или иной переменной, что возвращает та или иная функция. Какие действия мы можем совершить над нашими данными. Это база. Поэтому именно этому я и хотел уделить внимание в данной статье в общем, а так же примерам работы с бинарными данными в частности.

Материал в первую очередь адресую тем кто только начал или хочет начать писать на Erlang-e. Но я постарался максимально полно охватить данный аспект языка и поэтому надеюсь, что написанное будет полезно и более продвинутой аудитории.

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

Вступление


Для начала хочу выразить огромную признательность участникам русскоязычной рассылке по Erlang-у в Google-е за поднятие кармы и возможности выложить на хабр данную статью.

В процессе изложения будут приводиться примеры из командной оболочки (шелла) Эрланга. Поэтому нужно усвоить простые принципы работы. Каждая команда в шелле разделяется запятыми. При это совершенно не важно, производится набор в одну строку или в несколько.

1> X=1, Y = 2,
1> Z = 3,
1> S=4.
4

Указателем завершения ввода и запуск на выполнение является точка. При этом шелл выведет на экран значение возвращаемое последней из команд. В примере выше возвращается значение переменной S. Значения всех инициированных переменных запоминается, а так как в Эрланге нельзя переопределить значение инициированной переменной, то попытка переопределения приведет к ошибке:

2> Z=2.
** exception error: no match of right hand side value 2

Поэтому если требуется в текущей сессии работы заставить шелл «забыть» значения переменных, то можно воспользоваться функцией f(). Вызванная без аргументов она удаляет все инициализированные переменные. Если в качестве аргумента указать имя переменной, то удалена будут только она (передать список переменных нельзя):

3> f(Z).
ok
4> X = 4.
** exception error: no match of right hand side value 4
5> f().
ok
6> X = 4. %все, что идет после знака процента является комментарием
4

Для выхода достаточно ввести halt(), или вызвать интерфейс пользовательских команд Crtl+G и ввести q (команда h выведет справку). При выводе цифровых данных в шелле они приводятся к десятичному виду.

Изложенный материал относится к последней, актуальной на данный момент, версии 5.6.5. Для кодирования строк используется ISO-8859-1 (Latin-1) кодировка. Соответственно и все численные коды символов берутся из этой кодировки. Первая половина (коды 0-127) кодировки соответствует кодам US-ASCII, поэтому проблем с латинским алфавитом не возникает.

Несмотря на заявление разработчиков о том, что во внутреннем представлении используется Latin-1 «снаружи» виртуальной машины это зачастую совершенно неочевидно. Это возникает оттого, что Эрланг передает и принимает символы в виде кодов. Если для терминала установлена локаль, то коды интерпретируются исходя из установленной кодовой страницы и если это возможно выводятся в виде печатных символов. Вот пример SSH сессии:
# setenv | grep LANG
LANG=
# erl
Erlang (BEAM) emulator version 5.6.5 [source] [async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.6.5 (abort with ^G)
1> [255].
"\377"
2> halt().
# setenv LANG ru_RU.CP1251
# erl
Erlang (BEAM) emulator version 5.6.5 [source] [async-threads:0] [hipe] [kernel-poll:false]

Eshell V5.6.5 (abort with ^G)
1> [255].
"я"

Причем совершенно не важно, что указано в переменной окружения LANG, главное что бы она была установлена.

1. Элементарные типы


В языке не очень много базовых типов. Это число (целое или с плавающей запятой), атом, двоичные данные, битовые строки, функции-объекты (аналогично JavaScript-у), идентификатор порта, идентификатор процесса (Erlang процесса, а не системного), кортеж, список. Существует ряд псевдотипов: запись, булев, строки. Любой тип данных (не обязательно элементарный) называется терм.

1.1 Число

Поддерживается два типа чисел. Числа с плавающей запятой (Float) и целые (Integer). Кроме общепринятых форм записи чисел существует две специфичных нотации:
  • $char
    ASCII код (в зависимости от локали) символа char.
  • base#value
    целое число value в системе счисления с основанием base, основание может быть из диапазона 2…36

Например:
1> 42.
42
2> $A.
65
3> $\ .
10
4> 2#101.
5
5> 16#1f.
31
6> 2.3.
2.3
7> 2.3e3.
2.3e3
8> 2.3e-3.
0.0023
9>$я.
255

Напоминаю, что цифры при выводе приводятся к десятичному виду.

Потребление памяти и ограничения. Целое занимает одно машинное слово, что для 32-ух и 64-х разрядных процессорах составляет 4 байта и 8 байт соответственно. Для больших целых 1…N машинных слов. Числа с плавающей точкой в зависимости от архитектуры занимают 4 и 3 машинных слова соответственно.

1.2 Список

Список (List) позволяют группировать данные в одну структуру. Список создается при помощи квадратных скобок, элементы списка разделяются запятыми. Элемент списка может быть любого типа. Первый элемент списка называется голова (head), а оставшаяся часть — хвост (tail).

1> Var = 5.
5
2> [1,45, atom, Var,"string", $Z, 2#101].
[1,45,atom,5,"string",90,5]
3>List = [1, 9.2, 3], %голова списка 1, хвост [9.2, 3]
3>List.
[1,9.2,3]

Размер списка равен количеству элементов в нем. В примере выше значением переменной List является список, размер списка равен 3.
Список является динамической структурой. Можно добавлять и удалять элементы списка. Внутри виртуальной машины список представляет собой структуру, которая является односвязным списком, что накладывает определенные особенности обработки, но об этом чуть ниже.

Потребление памяти и ограничения. Каждый элемент списка занимает одно машинное слово (4 или 8 байт в зависимости от архитектуры) + размер хранимых в элементе данных. Таким образом на 32-ух разрядной архитектуре значение переменной List будет занимать (1 + 1) + (1 + 4) + (1 + 1) = 9 слов или 36 байт.

1.3 Строка

На самом деле в Эрланге нет строк (String). Это просто синтаксический сахар который позволяет в более удобной форме записывать список целых чисел. Каждый элемент этого списка представляет собой ASCII код соответствующего символа.

1> "Surprise".
"Surprise"
2> [83,117,114,112,114,105,115,101].
"Surprise"
3> "строка".
"строка"
4> [$с,$т,$р,$о,$к,$а].
"строка"
5> [$с, $т, $р, $о, $к, $а, 1].
[241,242,240,238,234,224,1]

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

Потребление памяти и ограничения. Т.к. строка это список целых чисел, а каждый символ это один элемент списка, то на символ уходит 8 или 16 байт (2 машинных слова).

1.4 Атом

Атом (Atom) это просто литерал. Он не может быть связан с каким либо цифровым значением подобно константе в других языках. Значение возвращаемое атомом является самим атомом. Атом должен начинаться со строчной буквы и состоят из цифр, латинских букв, знака подчеркивания _ или собачки @. В этом случае его можно не заключать в одиночные кавычки. Если имеется другие символы, то нужно использовать одиночные кавычки для обрамления. Двойные кавычки для этого не подходят, т.к. в них заключают строки.

Например:
hello
phone_number
'Monday'
'phone number'

В строках и в закавыченных атомах можно использовать такие управляющие последовательности:
Sequence
Description
\b
возврат (backspace)
\d
удалить (delete)
\e
эскейп (escape)
\f
прогон страницы (form feed)
\
новая строка (newline)
\r
возврат каретки (carriage return)
\s
пробел (space)
\t
горизонтальная табуляция (tab)
\v
вертикальная табуляция (vertical tab)
\XYZ, \YZ, \Z
восьмеричный код символа
\^a...\^z
\^A...\^Z
Ctrl + A … Ctrl + Z
\'
одиночная кавычка
\"
двойная кавычка
\\
обратная косая черта

Имя незаковыченного атома не может быть зарезервированным словом. К таким словам относятся:
after and andalso band begin bnot bor bsl bsr bxor case catch cond div end fun if let not of or orelse query receive rem try when xor.

Потребление памяти и ограничения. Каждый объявленный атом является уникальным и его символьное представление хранится во внутренней структуре виртуальной машины которая называется таблица атомов. Атом занимает 4 или 8 байт (одно машинное слово) и является просто ссылкой на элемент таблицы атомов в котором содержится его символьное представление. Сборщик мусора (garbage-collection) не выполняет очистку таблицы атомов. Сама таблица так же занимает место в памяти. Допускается использовать атомы в 255 символов, в общей сложности допустимо использовать 1 048 576 атомов. Таким образом атом в 255 символов будет занимать 255 * 2 + 1 * N машинных слов, где N – количество упоминаний атома в программе.


1.5 Кортеж

Кортеж (Tuple) подобен списку и состоит из набора элементов. Он так же имеет размер равный количеству элементов, но в отличие от списка его размер фиксирован. Кортеж создается при помощи фигурных скобок и его элементами могут быть любые типы данных, кортежи могут быть вложенными.

1> {1,2, 2#110, n, $r, [1, 5], "abc"}.
{1,2,6,n,114,[1,5],"abc"}
2> Man = {man,
2>           {name, "Алексей"},
2>           {height, {meter, 1.86}},
2>           {age, 27}}.
{man,{name,"Алексей"},{height,{meter,1.86}},{age,27}}
3> Man2 = {man,
3>            {name, "Иван"},
3>            {height, {meter, 1.80}},
3>            {age, 25}}.
4> [Man, Man2].
[{man,{name,"Алексей"},{height,{meter,1.86}},{age,27}},
 {man,{name,"Иван"},{height,{meter,1.8}},{age,25}}]
5>{20, 100}.
{20,100}.

Кортежи удобны тем, что позволяют не только включать в структуру конкретные данные, но и описывать их. Это, а так же фиксированность кортежа позволяют очень эффективно применять их в шаблонах. Будет хорошей практикой при создании кортежа в первый элемент записывать атом описывающий сущность кортежа. Если проводить аналогии с РСУБД, то список является таблицей, каждая строка таблицы это элемент списка, а кортеж находящийся в этом элемент – конкретная запись в соответствующем столбце.

Потребление памяти и ограничения. Кортеж занимает 2 машинных слова + размер необходимый для хранения непосредственно самих данных. К примеру, кортеж в строке 5 будет занимать (2 + 1) + (2 + 1) = 6 машинных слов или 24 байта на 32-ой архитектуре. Максимальное количество элементов в кортеже 67 108 863.


1.6 Запись

Запись (Record) на самом деле является еще одним примером синтаксического сахара и во внутреннем представлении хранится как кортеж. Запись на этапе компиляции преобразуется в кортеж, поэтому использовать записи напрямую в шелле невозможно. Но можно воспользоваться rd() функцией для объявления структуры записи (строка 1). Объявление записи всегда состоит из двух элементов. Первый элемент обязательно должен быть атом называемый имя записи. Второй всегда кортежем, возможно даже пустым, элементы которого являются парой имя_полязначение_поля, при этом имя поля должно быть атомом, а значение любым допустимым типом (в том числе и записью, строка 11).


Оператором создания кортежа на основании записи (строка 2) является решетка # после которой следует имя записи и кортеж со значениями полей, возможно даже и пустым, но ни когда с именами полей которые не объявлены в описание записи.

1> rd(person, {name = "", phone = [], address}).
person
2> #person{}.
#person{name = [],phone = [],address = undefined}
3> #person{phone=[1,2,3], name="Joi", address="Earth"}.
#person{name = "Joi",phone = [1,2,3],address = "Earth"}

Преимущество записи перед кортежем в том, что работа с элементами осуществляется по имени поля (имя должно быть атомом и быть объявленным в описании записи, строка 1), а не по номеру позиции. Соответственно порядок присвоения не имеет значения, не нужно помнить, что у нас содержится в первом элементе имя, телефон или адрес. Можно изменить порядок следования полей, добавить новые поля. Кроме того возможно присвоение значения по умолчанию когда соответствующее поле не задано.

4> rd(person, {name="Smit", phone}).
person
5> P = #person{}.
#person{name = "Smit",phone = undefined}
6> J = #person{phone = [1,2,3], name = "Joi"}.
#person{name = "Joi",phone = [1,2,3]}
7> P#person.name.
"Smit"
8> J#person.name.
"Joi
9> W = J#person{name="Will"}.
#person{name = "Will",phone = [1,2,3]}

Если же при создании записи (строка 4) значение по умолчанию не определено (поле phone), то его значение равно атому undefined. Получить доступ к значению переменной созданной с помощью записи можно используя синтаксис описанный в строках 7 и 8. Можно скопировать значение переменной в новую переменную (строка 9). При этом если значение ни каких полей не определено, то получается полная копия, если же определены, то в новой переменной соответствующие поля переопределяются. Все эти манипуляции ни как не затрагивают ни определение записи, ни значения полей в старой переменной.
Лично мне это очень напоминает описание и создание экземпляров класса, хотя еще раз подчеркну, что это всего лишь способ хранения в переменной кортежа.

10> rd(name, {first = "Robert", last = "Ericsson"}).
name
11> rd(person, {name = #name{}, phone}).
person
12> P = #person{name = #name{first="Robert",last="Virding"}, phone=123}.
#person{name = #name{first = "Robert",last = "Virding"},
phone = 123}
13> First = (P#person.name)#name.first.
"Robert"

Пример выше иллюстрирует вложенные записи и синтаксис доступа к внутренним элементам.


1.7 Бинарные данные и битовые строки

И бинарный тип (Binaries) и битовые строки (Bit strings) позволяют работать с двоичным кодом напрямую. Отличие бинарного типа от битовой строки в том, что бинарные данные должны состоять только из целого количества байт, т.е. количество бит в них кратно восьми. Битовые же строки позволяют работать с данными на уровне бит, т.е. по сути бинарный тип это частный случай битовой строки количество разрядов в которой кратно восьми. Можно как создавать данные описав их структуру, так и использовать данный тип в шаблонах. Двоичные данные описываются такой структурой:

<<E1, E2, ... En>>

Отдельной элемент такой структуры называется сегмент. Сегменты описывают логическую структуру двоичных данных и могут состоят из произвольного числа битов/байтов. Это дает очень мощный и удобный инструмент при использовании в шаблонах (пример такого применения будет рассмотрен в третьей части).

1> <<20, $W, 50:8, "abc">>.
<<20,87,50, "abc" >>
2> <<400>>.
<<144>>
3> <<400:16>>.
<<1,144>>
4> Var = 30.
30
5> <<(Var + 30), (20+5)>>.
<<60,25>>

Что бы понять, почему в результате создания двоичных данных в строке 2 мы получили 144 (т.е. 10010000, ведь мы, надеюсь, еще не забыли, что шелл при выводе приводит все цифровые данные к десятичному виду), а не ожидаемые 400 нужно рассмотреть битовый синтаксис описания сегмента.

Ei = Value |
     Value:Size |
     Value/TypeSpecifierList |
     Value:Size/TypeSpecifierList

Полная форма описания сегмента состоит из значения (Value), размера (Size) и спецификатора ( TypeSpecifierList ). Причем размер и спецификатор являются необязательными и если не заданы принимают значения по умолчанию.

Значение (Value) в конструкторе может быть числом (целым или с плавающей точкой), битовой строкой или строкой, которая, как мы помним, является на самом деле списком целых чисел. Однако вместе с тем значение сегмента не может быть списком даже целых чисел, т.к. внутри конструктора строка является синтаксическим сахаром для посимвольного преобразования в целые числа, а не в список. Т.е. запись <<«abc”>> является синтаксическим сахаром для <<$a, $b, $c>>, а не <<[$a, $b, $c]>>.
Внутри шаблонов значение может быть литералом или неопределенной переменной. Вложенные шаблоны недопускаются. В Value так же можно использовать выражения, но в этом случае сегмент должен быть заключен в круглые скобки (строка 5).

Размер (Size) определяет размер сегмента в юнитах (Unit, о них чуть ниже) и должен быть числом. Значение по умолчанию Size зависит от типа (Type, см. ниже) Value, но может быть и явно задано. Для целых это 8, чисел с плавающей точкой 64, бинарный соответствует количеству байт, битовые строки количеству разрядов. Полный размер сегмента в битах можно вычислить как Size * Unit.
При использовании в шаблонах величина Size должна быть явно заданной (строка 7) и может не задаваться только для последнего сегмента поскольку в него попадает остаток данных (сродни чтению строки со start символа и до конца строки без указания нужного length количества символов).

6> Bin = <<30>>.
<<30>>
7> <<X:2, Y:3, Z/bits>> = Bin,
8> Z. %переменная размером в 3 разряда, её значение 110
<<6:3>>


Спецификатор ( TypeSpecifierList ) состоит из списка уточняющих опций разделенных дефисом и записанных в произвольном порядке (для большего удобства чтения рекомендую писать unit последним).
  • Type = integer | float | binary | bytes | bitstring | bits
    Указывает тип Value. bytes является короткой формой записи для binary, а bits для bitstring. Значение по умолчанию integer.
  • Signedness = signed | unsigned
    Указывает есть ли у целого знак или это без знаковая величина. Имеет смысл только для целого типа. Значение по умолчанию unsigned (т.е. положительно целое без знака).
  • Endianness = big | little | native
    Порядок байтов. Одним байтом можно закодировать диапазон целых чисел 0…255, поэтому для бОльших чисел требуется два и более байт. К примеру, число 400 закодированное двумя байтами будет иметь вид 00000001 10010000 и первым тут считается старший байт (от старшего к младшему). Это сетевой порядок байт (big-endian). Когда же считается, что первым идет младший байт, то говорят об интеловском порядке байт (little-endian). Значение native означает, что порядок байт будет установлен при загрузке в зависимости от того, какой режим является «родным» для центрального процессора на котором выполняется виртуальная машина. Порядок имеет смысл только для чисел. Значение по умолчанию big.
  • Unit = unit:IntegerLiteral
    Юнит. Число в диапазоне 1…256. Вместе с Size однозначно определяет размер сегмента в битах и не может указываться без явного задания Size. Значение по умолчанию 1 для чисел и битовых строк, и 8 для бинарного типа.

Таким образом пример из строки 2 должен стать более понятным. В Эрланге конструктор двоичных данных по умолчанию создает сегменты равные одному байту если только явно не указать другой размер. Следовательно, в строке 2 содержится запись вида <<400:8/integer-unsigned-big-unit:1>>, которая и усекается виртуальной машиной до одного последнего байта. При сетевой последовательности байт последним будет байт со значением 10010000, т.е. 144 в десятичной системе. Если же задать последовательность little, то последним будет 00000001 байт, т.е. 1 в десятичной системе. Если же сегмент способен закодировать значение, то усечения происходить не будет.

9> <<400:16>>.
<<1,144>>
10> <<400:16/little>>.
<<144,1>>
11> <<400:8/unit:2>>.
<<1,144>>

Использую битовый синтаксис одни и те же данные могут быть описаны по разному (строки 9 и 11 описывают одну и туже двухбайтовую структуру).

Потребление памяти и ограничения. 3…6 бит + непосредственно сами данные. На 32-ой архитектуре возможна манипуляция 536 870 911байтами, на 64-ех разрядной системе 2 305 843 009 213 693 951 байтами. Для обработки структур бОльшего размера придется самостоятельно написать функции обработки.
Внимание. Запись B=<<1>> будет интерпретироваться как B =<<1>> (т.е. B меньше-равно <1>>). Правильная форма будет с применением пробелов: B = <<1>>.

1.8 Ссылка

Ссылка (Reference) представляет собой терм создаваемый функцией make_ref/0 и может считать уникальным. Она может быть использовать для такой структуры данных как первичный ключ.

Потребление памяти и ограничения. На 32-ух разрядной архитектуре требуется 5 машинных слов на одну ссылку для текущей локальной ноды и 7 слов для удаленной. На 64-ой 4 и 6 слов соответственно. Кроме того ссылка связана с таблицей нод которая также потребляет оперативную память.

1.9 Булев

Булев тип (Boolean) является псевдо типом т.к. на самом деле это всего лишь два атома true и false.


1.10 Объект-функция

fun
    (Pattern11,...,Pattern1N) [when GuardSeq1] ->
        Body1;
    ...;
    (PatternK1,...,PatternKN) [when GuardSeqK] ->
        BodyK
end

Объявление функции начинается с ключевого слова fun и завершается ключевым словом end. Набор параметров передается через запятую в круглых скобках, каждый параметр представляет собой шаблон. Если входные параметры совпадают с шаблоном, то выполняется набор инструкций от знака -> и до;. Т.е. по сути входные аргументы выполняют роль входных фильтров. Если не один шаблон не совпал, то генерируется сообщение об ошибке.

1> Fun = fun({centimetre, X}) -> {meter, X/100} end.
#Fun<erl_eval.6.13229925>
2> Fun(10).
** exception error: no function clause matching
erl_eval:'-inside-an-interpreted-fun-'(10)
3> Fun({centimetre, 10}).
{meter,0.1}

Но если определить функцию по другому, то ошибки в строке 2 не возникло бы:

4> f().
ok
5> Fun = fun({centimetre, X}) ->
5>               {meter, X/100};
5>           (X) ->
5>               X/100
5>       end.
#Fun<erl_eval.6.13229925>
6> Fun(10).
0.1

Поэтому входные аргументы должны быть того же типа, что объявленные в функции. После ключевого слова when и до -> можно включать выражение результатом которого является true либо false. Тело функции выполняется в случае если выражение возвращает true. Если в ходе всех проверок тело функции так и не было выполнено (т.е. функция ни чего не вернула), то генерируется ошибка (строка 12). Переменные внутри функции являются локальными.

7> F = fun(X) when X<0 ->
7>          X+1;
7>        (X) when X>0 ->
7>          X-1;
7>        (0) -> 0
7>     end.
#Fun<erl_eval.6.13229925>
8> F(5).
4
9> F(-5).
-4
10> F(0).
0
11> X = fun(Y) when Y>0 -> Y + 1 end.
#Fun<erl_eval.6.13229925>
12> X(-5).
** exception error: no function clause matching erl_eval:'-inside-an-interpreted-fun-'(-5)
13> X(5). 
6


Функции могут быть вложенными, при этом результатом возвращаемым внешней функцией будет внутренняя:

14> Adder = fun(X) -> fun(Y) -> X + Y end end.
#Fun<erl_eval.6.72228031>
15> Add6 = Adder(6).
#Fun<erl_eval.6.72228031>
16> Add6(10).
16

Потребление памяти и ограничения. На функцию уходит 9…13 машинных слов + размер окружения. Кроме того функция связана с таблицей функций которая так же занимает память.

1.11 Идентификатор процесса

Идентификатор процесса (Pid) возвращают функции spawn/1,2,3,4, spawn_link/1,2,3,4 and spawn_opt/4 при создании Эрланг процесса. Удобным механизмом взаимодействия с процессом может быть обращение к нему по имени, а не через цифровой идентификатор. Поэтому в Эрланге есть возможность связывать с Pid-ом символическое имя и в дальнейшем посылать сообщения процессу используя его имя.

1> spawn(m, f, []).
<0.51.0>


Потребление памяти и ограничения. На данный тип уходит 1 машинное слово для локальных и 5 для удаленных нод. Кроме того функция связана с таблицей нод которая так же занимает память.


1.12 Идентификатор порта

Идентификатор порта ( Port Identifier) возвращает функция open_port/2 при создании порта. Порт представляет собой базовый механизм взаимодействия между Эрланг процессами и внешним миром. Он предоставляет байт ориентированный интерфейс связи с внешним программами. Процесс создавший порт называется владелец порта или процессом присоединенным к порту. Все данные проходящие через порт так же проходят и через владельца порта. После завершения процесса сам порт и внешняя программа должны так же завершиться. Внешняя программа должна представлять собой другой процесс операционной системы который принимает и отправляет данные со стандартного входа и выхода. Любой Эрланг процесс может послать данные через порт, но только владелец порта может получить данные через него. Для идентификатора порта, как и для идентификатора процесса, может быть зарегистрировано символьное имя.

Потребление памяти и ограничения. На данный тип уходит 1 машинное слово для локальных и 5 для удаленных нод. Кроме того функция связана с таблицей нод и таблицей портов которые так же занимает память.


P.S. На этом первую часть с описанием базовых типов я и завершаю. До того момента пока я не взялся за продолжение крайне рекомендую, для тех, кто еще не успел, ознакомиться с „Начала работы с Erlang“ которая является переводом одной из глав документации „Getting Started With Erlang
Tags:
Hubs:
+41
Comments 69
Comments Comments 69

Articles