The Golang Handbook

EminelEminel
17 min read

Lời mở đầu

Ngôn ngữ lập trình Go đang bùng nổ về mức độ phổ biến. Rất nhiều công ty đang sử dụng Go để xây dựng hạ tầng backend hiện đại và có khả năng mở rộng cao.

Nếu bạn đang tìm kiếm một ngôn ngữ lập trình mới để học, Go là một lựa chọn tuyệt vời. Ngôn ngữ này nhanh, nhẹ, có cộng đồng mã nguồn mở rất mạnh và thực sự khá dễ để bắt đầu. Bài viết này đóng vai trò khởi đầu cho loạt series học Golang.

Chapter 1 - Why Learn Go?

Go nhanh, đơn giản và hiệu quả. Đây là một trong những ngôn ngữ lập trình nhanh nhất, vượt trội so với JavaScript, Python và Ruby trong hầu hết các bài benchmark.

Tuy nhiên, mã Go không chạy nhanh bằng các ngôn ngữ biên dịch như Rust hay C. Dù vậy, Go có tốc độ biên dịch nhanh hơn nhiều, giúp trải nghiệm lập trình cực kỳ hiệu quả.

Cách tải xuống và cài đặt công cụ Go

Tôi đề xuất 2 hướng sau:

Một lưu ý về cấu trúc của một chương trình Go

Trước tiên hãy xem qua đoạn code sau:

package main

import "fmt"

func main() {
    fmt.Println("hello world")
}
  1. Khi bạn viết package main, tức là bạn nói với Go rằng: "Tôi muốn chương trình này khi biên dịch xong thì có thể tự chạy được.". Ngược lại, nếu bạn ghi package something_else, thì Go sẽ hiểu file này là một thư viện — chỉ để các chương trình khác nhập vào dùng (chứ nó tự thân không thể chạy được).

  2. import fmt dùng để nhập gói fmt (formatting). Gói này nằm trong thư viện chuẩn của Go và cho phép chúng ta thực hiện các thao tác như in văn bản ra console.

  3. func main() định nghĩa hàm main. main là tên hàm đóng vai trò điểm bắt đầu (entry point) của một chương trình Go.

Chapter 2 – How to Compile Go Code

Biên dịch có nghĩa là gì?

Máy tính chỉ hiểu mã máy – chúng không hiểu tiếng Anh, thậm chí cũng không hiểu ngôn ngữ lập trình như Go. Chúng ta cần chuyển đổi mã nguồn cấp cao (như Go) thành ngôn ngữ máy, thực chất chỉ là một tập hợp các chỉ thị mà phần cứng cụ thể (CPU của bạn) có thể hiểu và thực thi.

Công việc của trình biên dịch Go là lấy mã Go và tạo ra mã máy.
Trên Windows, đầu ra sẽ là một file .exe.
Trên Mac hoặc Linux, đó sẽ là một file thực thi (executable file).

Máy tính vốn không biết tự làm gì cả, trừ khi lập trình viên ra lệnh cho chúng.
Và tiếc là, máy tính cũng không hiểu ngôn ngữ con người – thậm chí còn không hiểu cả chương trình máy tính nếu nó chưa được biên dịch.

Máy tính cần mã máy

CPU của máy tính chỉ hiểu bộ lệnh riêng của nó, gọi là mã máy (machine code). Các lệnh này thường là những phép toán cơ bản như cộng, trừ, nhân và lưu trữ dữ liệu tạm thời.

Ví dụ, một bộ xử lý ARM sẽ sử dụng lệnh ADD khi được cung cấp số nhị phân 0100.

Go, C, Rust và các ngôn ngữ khác

Go, C và Rust đều là những ngôn ngữ mà mã nguồn phải được chuyển thành mã máy thông qua trình biên dịch trước khi có thể thực thi.

Biên dịch (Compiled) vs Thông dịch (Interpreted)

Các chương trình đã biên dịch có thể được chạy mà không cần truy cập vào mã nguồn gốckhông cần trình biên dịch.

Ví dụ, khi trình duyệt thực thi đoạn mã bạn viết trong khóa học này, nó không sử dụng mã nguồn gốc, mà chỉ dùng kết quả đã biên dịch.
Hãy lưu ý rằng điều này khác với các ngôn ngữ thông dịch như Python và JavaScript.

Với Python và JavaScript, mã nguồn sẽ được thông dịch tại thời điểm chạy bởi một chương trình riêng gọi là interpreter (trình thông dịch).
Việc phân phối mã để người dùng tự chạy có thể trở nên bất tiện, vì họ cần phải cài đặt trình thông dịch và phải có quyền truy cập vào mã nguồn gốc.

Ví dụ về ngôn ngữ biên dịch:

  • Go

  • C

  • C++

  • Rust

Ví dụ về ngôn ngữ thông dịch:

  • JavaScript

  • Python

  • Ruby

Go là ngôn ngữ Strongly Typed

Go áp dụng kiểu dữ liệu mạnh và tĩnh (strong and static typing), nghĩa là mỗi biến chỉ có thể có một kiểu dữ liệu duy nhất.
Ví dụ, một biến kiểu chuỗi như "hello world" sẽ không thể bị đổi thành số nguyên (int) như 3.

Một trong những lợi ích lớn nhất của kiểu mạnh là lỗi sẽ được phát hiện ngay trong lúc biên dịch.
Nói cách khác, các lỗi được phát hiện sớm khi biên dịch, trước cả khi chương trình chạy.

Điều này trái ngược với hầu hết các ngôn ngữ thông dịch, nơi kiểu dữ liệu của biến là động (dynamic).
Việc dùng kiểu động có thể dẫn đến những lỗi rất khó phát hiện. Với ngôn ngữ thông dịch, lỗi chỉ xuất hiện khi chạy chương trình (đôi khi thậm chí là trong môi trường production nếu bạn xui 😨).

Ví dụ, đoạn mã sau trong Go sẽ không thể biên dịch vì bạn không thể cộng chuỗi và số nguyên với nhau:

package main

import "fmt"

func main() {
    var username string = "wagslane"
    var password int = 20947382822

    // don't edit below this line
    fmt.Println("Authorization: Basic", username+":"+password)
}
# command-line-arguments
./main.go:10:38: invalid operation: username + ":" + password (mismatched types string and int)

Chương trình Go rất nhẹ (lightweight)

Các chương trình Go khá nhẹ. Mỗi chương trình Go chỉ kèm theo một lượng nhỏ mã bổ sung được đóng gói sẵn trong file thực thi (binary). Phần mã bổ sung này gọi là Go Runtime.

Một trong những nhiệm vụ của Go Runtimedọn dẹp bộ nhớ không còn sử dụng trong quá trình chạy chương trình.

Nói cách khác, trình biên dịch Go tự động thêm một chút logic vào mỗi chương trình để giúp lập trình viên dễ dàng viết code sử dụng bộ nhớ hiệu quả hơn.

Thông thường, các chương trình Java sẽ tiêu tốn nhiều bộ nhớ hơn chương trình Go tương đương, vì Java cần chạy trên một máy ảo (Virtual Machine), còn Go thì chỉ cần Go Runtime rất nhỏ gọn.
Go Runtime nhỏ đến mức nó được đóng gói trực tiếp vào file thực thi đã biên dịch.

Ngoài ra, theo quy luật chung, các chương trình viết bằng Rust và C++ thường dùng ít bộ nhớ hơn so với Go vì lập trình viên có quyền kiểm soát trực tiếp nhiều hơn trong việc tối ưu bộ nhớ.
Trong khi đó, Go Runtime sẽ tự động xử lý việc quản lý bộ nhớ cho chúng ta.

Trong biểu đồ phía trên, Dexter Darwich đã so sánh mức sử dụng bộ nhớ của ba chương trình rất đơn giản viết bằng Java, Go và Rust. Như bạn có thể thấy, Go và Rust sử dụng rất ít bộ nhớ khi so với Java.

Chapter 3 – Variables in Go

Các kiểu biến cơ bản trong Go:

  • bool

  • string

  • int int8 int16 int32 int64

  • uint uint8 uint16 uint32 uint64 uintptr

  • byte (alias cho uint8)

  • rune (alias cho int32, đại diện cho Unicode code point)

  • float32 float64

  • complex64 complex128

Chúng ta đã đề cập stringint trước đó, và chúng khá dễ hiểu.

  • bool: kiểu dữ liệu boolean (chỉ nhận true hoặc false).

  • float32, float64: kiểu số thực có dấu phẩy động, dùng cho các số như 3.14159. float32 có độ chính xác 32-bit, float64 64-bit.

Các kiểu còn lại sẽ được giải thích sâu hơn ở các phần sau.

Cách khai báo biến

Sử dụng từ khóa var. Ví dụ:

var number int
var pi float64 = 3.14159

Nếu khai báo biến nhưng không gán giá trị, nó sẽ nhận giá trị mặc định (zero value) của kiểu đó.

Khai báo biến ngắn gọn (:=)

Trong các hàm, có thể dùng := để khai báo và khởi tạo nhanh. Go sẽ suy luận kiểu dựa trên giá trị gán:

empty := ""
numCars := 10
temperature := 0.0
isFunny := true

Lưu ý: := chỉ dùng được bên trong hàm, không dùng ở phạm vi package.

Suy luận kiểu (Type Inference)

Khi gán giá trị trực tiếp, Go tự động suy luận kiểu:

i := 42          // int
f := 3.14        // float64
g := 0.867 + 0.5i // complex128

Tuy nhiên, khi giá trị bên tay phải là giá trị theo nghĩa đen biến mới sẽ là INT, Float64 hoặc Complex128 tùy thuộc vào độ chính xác của nó:

Khai báo nhiều biến trên một dòng

mileage, company := 80276, "Tesla"
// is the same as

mileage := 80276
company := "Tesla"

Kích thước kiểu dữ liệu (Type Sizes)

  • int, uint: kích thước 32-bit hoặc 64-bit tùy môi trường.

  • float64: số thực.

  • complex128: số phức.

Nên ưu tiên các kiểu tiêu chuẩn:

  • bool

  • string

  • int

  • uint

  • byte

  • rune

  • float64

  • complex128

Chuyển đổi kiểu dữ liệu (Type Casting)

temperatureInt := 88
temperatureFloat := float64(temperatureInt)

Nếu chuyển từ float sang int sẽ cắt bỏ phần thập phân.

Nên chọn kiểu nào?

Mặc định, nên dùng:

  • bool

  • string

  • int

  • float64

  • byte

  • rune

  • complex128

Tránh dùng uint16, int8, v.v. nếu không thực sự cần, vì dễ gây rối khi ép kiểu.

Hằng số (Constants)

Hằng số khai báo bằng const, không dùng được :=.

Ví dụ:

const myInt = 15
const firstName = "Lane"
const lastName = "Wagner"
const fullName = firstName + " " + lastName

Hằng số phải xác định tại thời điểm biên dịch (compile time).

Cách format chuỗi trong Go

Go sử dụng cú pháp printf giống ngôn ngữ C.

  • fmt.Printf – in ra console.

  • fmt.Sprintf – trả về chuỗi đã format.

Một số ký tự định dạng:

Ký tựÝ nghĩa
%vIn giá trị mặc định
%sIn chuỗi
%dIn số nguyên dạng thập phân
%fIn số thực
fmt.Sprintf("I am %v years old", 10)
fmt.Sprintf("I am %s years old", "twenty")
fmt.Sprintf("I am %d years old", 10)
fmt.Sprintf("I am %.2f years old", 10.523)

Conditionals

Cú pháp if trong Go không cần dấu ngoặc quanh điều kiện:

if height > 4 {
    fmt.Println("You are tall enough!")
}

Có thể dùng else ifelse như bình thường:

if height > 6 {
    fmt.Println("Super tall!")
} else if height > 4 {
    fmt.Println("Tall enough!")
} else {
    fmt.Println("Not tall enough!")
}

Câu lệnh khởi tạo trong if

Go cho phép thêm một lệnh khởi tạo ngay trong if:

if length := getLength(email); length < 1 {
    fmt.Println("Email is invalid")
}

Biến length chỉ tồn tại bên trong khối if, giúp giảm ô nhiễm phạm vi (scope pollution).

Còn tiếp…

Chapter 4 – Functions in Go

Các hàm trong Go có thể nhận không hoặc nhiều tham số.

Để mã Go dễ đọc hơn, kiểu dữ liệu được đặt sau tên biến.

Ví dụ, hàm sau:

func sub(x int, y int) int {
  return x - y
}

Nhận hai tham số kiểu int và trả về một giá trị int.

Cụm func sub(x int, y int) int được gọi là chữ ký hàm (function signature).

Nhiều tham số cùng kiểu

Khi nhiều tham số có cùng kiểu dữ liệu, chỉ cần khai báo kiểu sau biến cuối cùng (nếu chúng theo đúng thứ tự).

Ví dụ:

func add(x, y int) int {
  return x + y
}

Nếu không theo thứ tự, cần khai báo riêng kiểu cho từng biến.

Cú pháp khai báo hàm

Nhiều lập trình viên thắc mắc tại sao cú pháp khai báo trong Go lại khác với chuẩn của họ C. Trong C, khai báo gồm tên biến và kiểu dữ liệu đi kèm:

Cú pháp kiểu C

int y;

Kiểu dữ liệu ở bên trái, tên biến bên phải.

Tuy nhiên, trong chữ ký hàm phức tạp, cách viết kiểu C dễ gây rối:

int (*fp)(int (*ff)(int x, int y), int b)

Cú pháp kiểu Go

Trong Go, khai báo được đọc từ trái sang phải, giống như đọc tiếng Anh:

x int
p *int
a [3]int

Cách này giúp những chữ ký phức tạp dễ đọc hơn:

f func(func(int,int) int, int) int

Truyền biến theo giá trị (Pass by Value)

Mặc định trong Go, biến được truyền theo giá trị: hàm nhận một bản sao của biến gốc, nên không thay đổi được giá trị ban đầu.

Ví dụ:

func main(){
    x := 5
    increment(x)

    fmt.Println(x) // vẫn in ra 5
}

func increment(x int){
    x++
}

Bỏ qua giá trị trả về

Có thể bỏ qua giá trị trả về bằng cách sử dụng dấu gạch dưới _.

Ví dụ:

func getPoint() (x int, y int) {
  return 3, 4
}

x, _ := getPoint()

getPoint() trả về hai giá trị, ta chỉ lấy x và bỏ qua y.

Tại sao cần bỏ qua giá trị trả về?

Có thể bạn chỉ cần một trong số các giá trị trả về, ví dụ chỉ cần bán kính mà không cần tâm đường tròn.

Lưu ý: Go sẽ báo lỗi nếu có biến khai báo nhưng không sử dụng — vì vậy cần chủ động bỏ qua biến không dùng.

Giá trị trả về có tên (Named Return Values)

Có thể đặt tên cho các giá trị trả về, chúng sẽ giống như biến mới được khai báo ở đầu hàm.

Ví dụ:

func getCoords() (x, y int){
  return
}

Tương đương với:

func getCoords() (int, int){
  var x int
  var y int
  return x, y
}

Khi dùng tên, bạn có thể:

  • Trả về rõ ràng (explicit return):

      func getCoords() (x, y int){
        return x, y
      }
    
  • Hoặc trả về ngầm định (naked return):

      func getCoords() (x, y int){
        return
      }
    

Nếu muốn ghi đè giá trị trả về, bạn cần dùng return rõ ràng.

Lợi ích của giá trị trả về có tên

Tài liệu hóa chức năng (Documentation)

Việc đặt tên cho giá trị trả về giúp người đọc hiểu ngay chức năng của hàm từ chữ ký, không cần đọc thân hàm.

Ví dụ:

func calculator(a, b int) (mul, div int, err error)

Dễ hiểu hơn:

func calculator(a, b int) (int, int, error)

Giảm số lượng mã (Sometimes)

Trong các hàm ngắn, bạn có thể bỏ qua phần liệt kê giá trị khi return (naked return). Tuy nhiên, nên hạn chế dùng trong các hàm dài vì làm giảm tính rõ ràng.

Trả về sớm (Early Returns)

Early Returns nghĩa là khả năng trả về kết quả sớm từ một hàm mà không cần thực thi toàn bộ hàm. Đây là một tính năng mạnh mẽ giúp làm cho mã nguồn dễ đọc hơn, đặc biệt khi được sử dụng như "guard clauses" (mệnh đề bảo vệ). Thực ra ở ngôn ngữ nào cũng có phần này cả.

Ví dụ truyền thống:

func divide(dividend, divisor int) (int, error) {
    if divisor == 0 {
        return 0, errors.New("Can't divide by zero")
    }
    return dividend/divisor, nil
}

So sánh giữa nested và guard clauses:

Nested (lồng nhiều lớp):

func getInsuranceAmount(status insuranceStatus) int {
  amount := 0
  if !status.hasInsurance(){
    amount = 1
  } else {
    if status.isTotaled(){
      amount = 10000
    } else {
      if status.isDented(){
        amount = 160
        if status.isBigDent(){
          amount = 270
        }
      } else {
        amount = 0
      }
    }
  }
  return amount
}

Dùng guard clauses:

func getInsuranceAmount(status insuranceStatus) int {
  if !status.hasInsurance(){
    return 1
  }
  if status.isTotaled(){
    return 10000
  }
  if !status.isDented(){
    return 0
  }
  if status.isBigDent(){
    return 270
  }
  return 160
}

Cách viết bằng guard clauses rõ ràng hơn, dễ đọc và giảm tải sự khó hiểu cho người đọc.

Chapter 5 – Structs in Go

Struct trong Go được dùng để biểu diễn dữ liệu có cấu trúc. Việc nhóm các biến khác loại vào cùng một cấu trúc thường mang lại sự tiện lợi. Ví dụ, để mô tả một chiếc xe, ta có thể khai báo:

type car struct {
  Make   string
  Model  string
  Height int
  Width  int
}

Cấu trúc trên tạo một kiểu struct tên car gồm các trường: Make, Model, Height và Width.

Trong Go, bạn sẽ thường dùng struct để biểu diễn thông tin mà trong Python bạn sẽ dùng dictionary, hoặc trong JavaScript là object literal.

Struct lồng nhau (Nested Structs)

Struct có thể được lồng nhau để mô tả các đối tượng phức tạp hơn:

type car struct {
  Make       string
  Model      string
  Height     int
  Width      int
  FrontWheel Wheel
  BackWheel  Wheel
}

type Wheel struct {
  Radius   int
  Material string
}

Truy cập các trường trong struct thông qua toán tử .:

myCar := car{}
myCar.FrontWheel.Radius = 5

Struct ẩn danh (Anonymous Structs)

Struct vô danh giống như struct thông thường, nhưng không có tên định danh và không thể tái sử dụng ở nơi khác.

Khai báo một struct vô danh bằng cách tạo instance trực tiếp:

myCar := struct {
  Make  string
  Model string
}{
  Make:  "tesla",
  Model: "model 3",
}

Có thể lồng struct vô danh bên trong struct khác:

type car struct {
  Make   string
  Model  string
  Height int
  Width  int
  Wheel  struct {
    Radius   int
    Material string
  }
}

Khi nào nên dùng struct ẩn danh?

Nên ưu tiên dùng struct có tên để dễ đọc, dễ tái sử dụng và tránh hiểu nhầm. Struct ẩn danh chỉ nên dùng khi chắc chắn rằng cấu trúc đó sẽ không được dùng lại — ví dụ: định nghĩa tạm thời cho dữ liệu JSON trong một HTTP handler.

Struct nhúng (Embedded Structs)

Go không phải là ngôn ngữ hướng đối tượng theo kiểu truyền thống vì thiếu class và kế thừa, nhưng nó hỗ trợ các tính chất của OOP như đa hình, đóng gói, trừu tượng hóa thông qua struct và interface, sử dụng composition thay cho inheritance.

Struct nhúng cung cấp cơ chế chia sẻ dữ liệu giữa các struct — tương tự inheritance ở mức dữ liệu.

type car struct {
  make string
  model string
}

type truck struct {
  // "car" is embedded, so the definition of a
  // "truck" now also additionally contains all
  // of the fields of the car struct
  car
  bedSize int
}

So sánh Embedded và Nested

Tiêu chíEmbedded Struct (Struct nhúng)Nested Struct (Struct lồng)
Định nghĩaStruct được nhúng vào struct khác không có tên trường.Struct được khai báo như một trường có tên trong struct khác.
Truy cập trườngTruy cập trực tiếp qua struct ngoài: b.FieldOfAPhải qua tên trường: b.FieldA.FieldOfA
Tính kế thừaCó "hành vi giống kế thừa": các trường và phương thức của struct nhúng được promote lên.Không có "kế thừa": các trường của struct lồng không được promote.
Tính rõ ràngDễ gây xung đột tên trường nếu nhiều struct nhúng có trường trùng tên.Rõ ràng, tránh xung đột vì truy cập qua tên trường.
Mục đích sử dụngDùng để tái sử dụng, mở rộng, hoặc muốn các trường/method của struct con được dùng trực tiếp.Dùng để tổ chức dữ liệu phức tạp, giữ tính đóng gói, rõ ràng cấu trúc dữ liệu.
  • Với embedded struct, các trường được “nâng lên” và có thể truy cập trực tiếp:

      type Person struct {
          Name string
          Age  int
      }
      type Employee struct {
          Person      // Embedded trực tiếp
          EmployeeID string
      }
    
      // Truy cập: e.Name, e.Age, e.EmployeeID
    
  • Với nested struct, bạn phải truy cập thông qua tên trường chứa struct đó.

      type Person struct {
          Name string
          Age  int
      }
      type Company struct {
          Name string
          CEO  Person // Nested
      }
    
      // Truy cập: c.CEO.Name, c.CEO.Age
    

Phương thức của Struct (Struct Methods)

Go không hỗ trợ hướng đối tượng hoàn chỉnh, nhưng cho phép định nghĩa method trên struct. Method là một hàm có một tham số đặc biệt gọi là receiver – được khai báo trước tên hàm.

type rect struct {
  width int
  height int
}

func (r rect) area() int {
  return r.width * r.height
}

r := rect{
  width: 5,
  height: 10,
}

fmt.Println(r.area())  // in ra 50

Phần (r rect) được gọi là "receiver" (bộ nhận), nó chỉ ra rằng phương thức area() được gắn với kiểu rect. Điều này có nghĩa là bất kỳ biến nào có kiểu rect đều có thể gọi phương thức này.

Phương thức area() tính diện tích của hình chữ nhật bằng cách nhân chiều rộng với chiều cao và trả về một số nguyên.

Điều đặc biệt của Go là cách nó sử dụng "phương thức với receiver" thay vì các lớp như trong lập trình hướng đối tượng truyền thống. Điều này cho phép bạn thêm các hành vi (methods) vào kiểu dữ liệu mà không cần phải đặt chúng trong một lớp.

Còn Tiếp…

0
Subscribe to my newsletter

Read articles from Eminel directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Eminel
Eminel

Hello, my name is Eminel a software engineer specializing in scalable software architecture, microservices, and AI-powered platforms. Passionate about mentoring, performance optimization, and building solutions that drive business success.