cv.cm

Lập trình · Ngôn ngữ lập trình

Hướng dẫn học lập trình Erlang cơ bản cho người mới

8 phút đọc

Trả lời nhanh

Erlang là ngôn ngữ hàm, đồng thời, được tạo ra tại Ericsson cho những hệ thống phải luôn hoạt động, như tổng đài điện thoại và máy chủ nhắn tin. Chương trình chạy trên máy ảo BEAM dưới dạng nhiều process nhẹ giao tiếp bằng tin nhắn và được supervisor khởi động lại khi lỗi. Trang này bao gồm biên dịch module, kiểu dữ liệu, khớp mẫu, đệ quy, danh sách và map, process, OTP và xử lý lỗi.

Erlang là gì và dùng để làm gì

Erlang là ngôn ngữ hàm, kiểu động, được thiết kế tại Ericsson vào những năm 1980 cho các tổng đài điện thoại phải chạy không ngừng. Nó được phát hành mã nguồn mở năm 1998. Chương trình Erlang chạy trên máy ảo BEAM dưới dạng nhiều process nhẹ không chia sẻ gì với nhau và giao tiếp bằng tin nhắn, đi kèm OTP (Open Telecom Platform), bộ thư viện và nguyên tắc thiết kế để xây dựng hệ thống chịu lỗi.

Erlang được dùng trong thiết bị viễn thông, nền tảng nhắn tin (backend của WhatsApp nổi tiếng là được xây trên Erlang), message broker như RabbitMQ, cơ sở dữ liệu như Riak và CouchDB, cùng các hệ thống thanh toán và IoT. Elixir và Gleam cũng chạy trên BEAM và có thể dùng trực tiếp thư viện Erlang.

Cài Erlang và chạy Hello, world

Cài Erlang/OTP từ erlang.org hoặc bằng trình quản lý gói (ví dụ brew install erlang hoặc apt install erlang). Mã nằm trong module; mỗi tệp name.erl định nghĩa module name và liệt kê các hàm nó xuất ra (export).

% hello.erl
-module(hello).
-export([main/0]).

main() ->
    io:format("Hello, world!~n").
erlc hello.erl                          # compiles to hello.beam
erl -noshell -s hello main -s init stop
# Hello, world!
erl                                     # interactive shell; c(hello). then hello:main().

Chú thích bắt đầu bằng %. ~n trong chuỗi định dạng là xuống dòng và ~p in bất kỳ term nào. Trong shell, mỗi biểu thức kết thúc bằng dấu chấm.

Dấu câu, biến và các kiểu dữ liệu cơ bản

Dấu câu của Erlang là điều gây bất ngờ lớn nhất cho người mới: dấu phẩy ngăn cách các biểu thức, dấu chấm phẩy ngăn cách các mệnh đề, và dấu chấm kết thúc một hàm hoặc một lệnh trong shell. Biến bắt đầu bằng chữ hoa hoặc dấu gạch dưới và chỉ được gán một lần.

1> Name = "Erlang".
"Erlang"
2> Year = 1986, Next = Year + 1.
1987
3> Year = 2000.
** exception error: no match of right hand side value 2000
4> 10 / 4.
2.5
5> {10 div 4, 10 rem 4}.
{2,2}
6> is_atom(ok).
true
7> <<"binary string">>.
<<"binary string">>
KiểuVí dụGhi chú
integer42, 16#FFđộ chính xác tùy ý; 16# cho số hex
float3.14độ chính xác kép 64 bit
atomok, error, 'Hello'hằng số có tên, viết thường hoặc đặt trong nháy
tuple{ok, 42}nhóm giá trị có kích thước cố định
list[1, 2, 3]danh sách liên kết; chuỗi như "abc" là danh sách các số nguyên
map#{name => "Ann"}kho khóa-giá trị
binary<<"text">>chuỗi byte, cách hiệu quả để lưu văn bản

true và false là atom, không phải một kiểu riêng. = là phép khớp, không phải phép gán, vì vậy dòng 3 thất bại: Year đã được gán là 1986.

Khớp mẫu, case và guard

Khớp mẫu tách tuple, danh sách và map và chọn mệnh đề nào được chạy. Guard, viết bằng when, thêm điều kiện cho một mệnh đề. case và if là các biểu thức trả về giá trị.

-module(match).
-export([describe/1, grade/1, demo/0]).

describe({ok, Value}) -> io_lib:format("ok: ~p", [Value]);
describe({error, Reason}) -> io_lib:format("error: ~p", [Reason]);
describe(_Other) -> "unknown".

grade(Score) when Score >= 90 -> a;
grade(Score) when Score >= 70 -> b;
grade(_) -> c.

demo() ->
    [First | Rest] = [1, 2, 3],
    #{name := Name} = #{name => "Ann", age => 31},
    Size = case length(Rest) of
               0 -> empty;
               N when N < 5 -> small;
               _ -> large
           end,
    io:format("~p ~p ~s ~p ~p~n", [First, Rest, Name, Size, grade(75)]),
    io:format("~s~n", [describe({error, timeout})]).
% match:demo() prints:
% 1 [2,3] Ann small b
% error: timeout

Mọi mệnh đề trừ cái cuối đều kết thúc bằng ;, còn mệnh đề cuối kết thúc bằng .. Nếu không mệnh đề nào khớp, process phát sinh lỗi function_clause hoặc case_clause.

Hàm, đệ quy và hàm bậc cao

Hàm được xác định bằng tên và arity, viết là name/Arity. Không có vòng lặp; việc lặp dùng đệ quy, và hàm đệ quy đuôi chạy trong không gian ngăn xếp không đổi. Hàm ẩn danh viết là fun ... end.

-module(fns).
-export([factorial/1, sum/1, demo/0]).

factorial(0) -> 1;
factorial(N) when N > 0 -> N * factorial(N - 1).

sum(List) -> sum(List, 0).          % public wrapper
sum([], Acc) -> Acc;                 % tail-recursive helper sum/2
sum([H | T], Acc) -> sum(T, Acc + H).

demo() ->
    Double = fun(X) -> X * 2 end,
    io:format("~p~n", [factorial(20)]),                    % 2432902008176640000
    io:format("~p~n", [sum([1, 2, 3, 4])]),                % 10
    io:format("~p~n", [Double(21)]),                       % 42
    io:format("~p~n", [lists:map(Double, [1, 2, 3])]),     % [2,4,6]
    io:format("~p~n", [[X * X || X <- [1, 2, 3, 4, 5], X rem 2 =:= 1]]). % [1,9,25]

Dòng cuối dùng list comprehension. Lưu ý các toán tử so sánh: =:= và =/= kiểm tra bằng và khác chính xác, == và /= so sánh số theo giá trị (nên 1 == 1.0 là đúng), và nhỏ hơn hoặc bằng viết là =<, không phải <=.

Danh sách, map và thư viện chuẩn

Module lists cung cấp map, filter, foldl, sort, reverse và nhiều hàm khác; maps làm việc với map; string và binary xử lý văn bản. Tài liệu tham chiếu đầy đủ nằm trong tài liệu OTP.

1> L = [5, 3, 8, 1].
[5,3,8,1]
2> lists:sort(L).
[1,3,5,8]
3> lists:filter(fun(X) -> X > 2 end, L).
[5,3,8]
4> lists:foldl(fun(X, Acc) -> X + Acc end, 0, L).
17
5> M = #{ann => 31, bo => 25}, map_size(M).
2
6> M2 = M#{cy => 40}, map_size(M2).
3
7> maps:get(bo, M2).
25
8> maps:get(zed, M2, none).
none

M#{cy => 40} tạo một map mới; bản thân M không bao giờ thay đổi. Dùng := thay cho => để cập nhật một khóa bắt buộc đã tồn tại.

Process và truyền tin nhắn

Process là tính năng đặc trưng của Erlang. spawn khởi động một process mới, ! gửi tin nhắn, và receive chờ tin nhắn, khớp chúng theo mẫu. Process rất rẻ, nên một hệ thống có thể chạy rất nhiều process. Link và monitor cho phép một process nhận biết khi process khác chết, và các supervisor của OTP dùng điều này để tự động khởi động lại worker bị lỗi.

-module(counter).
-export([start/0, loop/1]).

start() ->
    Pid = spawn(counter, loop, [0]),
    Pid ! {add, 5},
    Pid ! {add, 10},
    Pid ! {get, self()},
    receive
        {count, N} -> io:format("count = ~p~n", [N])   % count = 15
    after 1000 ->
        io:format("timeout~n")
    end.

loop(Count) ->
    receive
        {add, N} -> loop(Count + N);
        {get, From} -> From ! {count, Count}, loop(Count)
    end.

Vòng lặp viết tay này chính là thứ mà behaviour gen_server của OTP khái quát hóa. Trong mã sản phẩm, bạn sẽ viết một module gen_server và đặt nó dưới một supervisor.

Xử lý lỗi và "let it crash"

Erlang có ba loại ngoại lệ: error, throw và exit. Các lỗi có thể đoán trước thường được trả về dưới dạng tuple {ok, Value} hoặc {error, Reason}. Những lỗi bất ngờ thường không bị bắt: process sập, và supervisor của nó khởi động lại ở một trạng thái tốt đã biết. Khi thật sự cần bắt, hãy dùng try ... catch.

1> file:read_file("missing.txt").
{error,enoent}
2> try 1 / 0 catch error:badarith -> division_failed end.
division_failed
3> try throw(my_reason) catch throw:R -> {caught, R} end.
{caught,my_reason}
4> case file:read_file("missing.txt") of {ok, Bin} -> Bin; {error, Why} -> Why end.
enoent

Một chương trình hoàn chỉnh nhỏ: tần suất từ

Module này đếm từ trong một chuỗi bằng map, sắp xếp theo số lần và in ba từ phổ biến nhất. Nó kết hợp các hàm chuỗi, lists:foldl, map và một phép sắp xếp tùy chỉnh.

-module(wordfreq).
-export([top/2, main/0]).

top(Text, N) ->
    Words = string:lexemes(string:lowercase(Text), " .,;:!?\n"),
    Counts = lists:foldl(fun(W, Acc) ->
                                 maps:update_with(W, fun(C) -> C + 1 end, 1, Acc)
                         end, #{}, Words),
    Sorted = lists:sort(fun({W1, C1}, {W2, C2}) ->
                                {-C1, W1} =< {-C2, W2}
                        end, maps:to_list(Counts)),
    lists:sublist(Sorted, N).

main() ->
    Text = "The cat and the dog. The dog sat, and the cat ran.",
    [io:format("~s: ~p~n", [W, C]) || {W, C} <- top(Text, 3)],
    ok.
% the: 4
% and: 2
% cat: 2

Học tiếp ở đâu

  • Tài liệu Erlang/OTP tại erlang.org, gồm hướng dẫn Getting Started và OTP Design Principles.
  • Learn You Some Erlang for Great Good!, một cuốn sách miễn phí trên mạng.
  • rebar3, công cụ build tiêu chuẩn, và Hex, trình quản lý gói dùng chung với Elixir.
  • Các trang tham chiếu gen_server và supervisor khi bạn xây dựng dịch vụ thực tế.

Câu hỏi thường gặp

Erlang còn được dùng không?

Có. Erlang vẫn vận hành hệ thống viễn thông, dịch vụ nhắn tin và RabbitMQ, và hệ sinh thái BEAM tiếp tục phát triển nhờ Elixir và Gleam, vốn có thể dùng thư viện Erlang.

OTP trong Erlang là gì?

OTP là bộ thư viện, công cụ và nguyên tắc thiết kế đi kèm Erlang. Nó cung cấp các behaviour như gen_server, supervisor và application để cấu trúc các chương trình chịu lỗi.

Nên học Erlang hay Elixir?

Elixir có cú pháp thân thiện hơn và hệ sinh thái web lớn hơn, còn Erlang nhỏ gọn hơn và là ngôn ngữ mà OTP cùng nhiều thư viện lõi được viết bằng nó. Cả hai chạy trên cùng một máy ảo, và biết một thì đọc được ngôn ngữ kia dễ dàng.

Let it crash nghĩa là gì?

Thay vì xử lý mọi lỗi có thể xảy ra bên trong một process, bạn để process thất bại và dựa vào supervisor khởi động lại nó ở trạng thái sạch. Mã xử lý lỗi nhờ vậy vẫn nhỏ và hệ thống tự phục hồi.

Vì sao biến Erlang viết hoa?

Erlang dùng chữ hoa để phân biệt biến với atom: Name là biến, còn name là atom, một giá trị hằng.

Tất cả hướng dẫn lập trình