Nguồn gốc
Mục đích của dự án là gì?
Vào thời điểm Go ra đời năm 2007, thế giới lập trình khác với ngày nay. Phần mềm chạy thật thường được viết bằng C++ hoặc Java, GitHub chưa tồn tại, phần lớn máy tính chưa phải đa bộ xử lý, và ngoài Visual Studio cùng Eclipse thì gần như không có IDE hay công cụ cấp cao nào khác, chứ đừng nói tới loại miễn phí trên Internet.
Trong khi đó, chúng tôi đã thấy bực bội với mức độ phức tạp thái quá cần thiết để xây dựng những dự án phần mềm lớn bằng các ngôn ngữ mình đang dùng và các hệ thống build đi kèm. Máy tính đã nhanh lên rất nhiều kể từ khi các ngôn ngữ như C, C++ và Java được phát triển lần đầu, nhưng bản thân việc lập trình lại chẳng tiến bộ được bao nhiêu. Ngoài ra, rõ ràng là các máy đa bộ xử lý đang trở nên phổ biến khắp nơi, nhưng đa số ngôn ngữ lại hỗ trợ rất ít cho việc lập trình chúng một cách hiệu quả và an toàn.
Chúng tôi quyết định lùi lại một bước và suy nghĩ xem những vấn đề lớn nào sẽ chi phối ngành công nghệ phần mềm trong những năm tới khi công nghệ phát triển, và một ngôn ngữ mới có thể giúp giải quyết chúng ra sao. Chẳng hạn, sự trỗi dậy của CPU đa nhân cho thấy một ngôn ngữ nên hỗ trợ ở mức first-class cho một dạng tính đồng thời hay tính song song nào đó. Và để việc quản lý tài nguyên trở nên khả thi trong một chương trình đồng thời cỡ lớn, cần có garbage collection, hoặc ít nhất là một dạng quản lý bộ nhớ tự động an toàn nào đó.
Những cân nhắc ấy dẫn tới một loạt các cuộc thảo luận mà từ đó Go ra đời, thoạt tiên là một tập hợp các ý tưởng và mong muốn, rồi sau đó thành một ngôn ngữ. Một mục tiêu bao trùm là Go phải làm được nhiều hơn để giúp người lập trình đang làm việc thực tế, bằng cách tạo điều kiện cho công cụ hoá, tự động hoá những việc tẻ nhạt như định dạng mã nguồn, và gỡ bỏ các trở ngại khi làm việc trên những cơ sở mã lớn.
Một mô tả rộng hơn nhiều về các mục tiêu của Go và cách chúng được đáp ứng, hay ít nhất là được tiếp cận, có trong bài viết Go at Google: Language Design in the Service of Software Engineering.
Lịch sử của dự án ra sao?
Robert Griesemer, Rob Pike và Ken Thompson bắt đầu phác thảo các mục tiêu cho một ngôn ngữ mới trên bảng trắng ngày 21 tháng 9 năm 2007. Chỉ trong vài ngày, các mục tiêu đã định hình thành một kế hoạch làm một cái gì đó cùng với hình dung khá rõ về việc nó sẽ là gì. Việc thiết kế tiếp tục theo kiểu bán thời gian, song song với những công việc không liên quan. Đến tháng 1 năm 2008, Ken đã bắt đầu làm một compiler để khám phá các ý tưởng; nó sinh ra mã C làm đầu ra. Đến giữa năm, ngôn ngữ đã trở thành một dự án toàn thời gian và đã ổn định đủ để thử làm một compiler dùng được trong thực tế. Tháng 5 năm 2008, Ian Taylor độc lập bắt tay vào một front end GCC cho Go dựa trên bản đặc tả nháp. Russ Cox tham gia vào cuối năm 2008 và giúp đưa ngôn ngữ cùng các thư viện từ nguyên mẫu thành hiện thực.
Go trở thành dự án mã nguồn mở công khai vào ngày 10 tháng 11 năm 2009. Vô số người trong cộng đồng đã đóng góp ý tưởng, thảo luận và mã nguồn.
Hiện nay có hàng triệu lập trình viên Go — các gopher — trên khắp thế giới, và mỗi ngày lại có thêm. Thành công của Go đã vượt xa mong đợi của chúng tôi.
Linh vật gopher có nguồn gốc từ đâu?
Linh vật và logo do Renée French thiết kế, người cũng đã thiết kế Glenda, chú thỏ của Plan 9. Một bài blog về con gopher giải thích nó được phát triển ra sao từ một hình bà từng dùng cho thiết kế áo phông WFMU vài năm trước. Logo và linh vật được cấp phép theo Creative Commons Attribution 4.0.
Con gopher có một model sheet minh hoạ các đặc điểm của nó và cách thể hiện chúng cho đúng. Model sheet này lần đầu được trình bày trong một bài nói của Renée tại Gophercon năm 2016. Nó có những nét riêng; nó là con gopher của Go, không phải một con gopher bất kỳ nào.
Ngôn ngữ này tên là Go hay Golang?
Ngôn ngữ này tên là Go. Biệt danh “golang” xuất hiện vì trang web ban đầu là golang.org. (Khi đó chưa có tên miền .dev.) Tuy vậy nhiều người vẫn dùng tên golang, và nó tiện lợi khi dùng làm nhãn. Chẳng hạn, thẻ mạng xã hội của ngôn ngữ này là “#golang”. Dù sao đi nữa, tên của ngôn ngữ vẫn chỉ đơn giản là Go.
Một ghi chú bên lề: Mặc dù logo chính thức có hai chữ cái viết hoa, tên ngôn ngữ vẫn được viết là Go, không phải GO.
Tại sao các bạn lại tạo ra một ngôn ngữ mới?
Go ra đời từ sự bực bội với các ngôn ngữ và môi trường sẵn có đối với công việc chúng tôi đang làm tại Google. Lập trình đã trở nên quá khó khăn, và một phần lỗi thuộc về sự lựa chọn ngôn ngữ. Người ta phải chọn hoặc là biên dịch hiệu quả, hoặc là thực thi hiệu quả, hoặc là dễ lập trình; cả ba thứ không cùng có mặt trong một ngôn ngữ phổ thông nào. Những lập trình viên có thể chọn thì đang chọn sự dễ dàng thay vì an toàn và hiệu năng, khi chuyển sang các ngôn ngữ kiểu động như Python và JavaScript thay vì C++, hay ở mức độ thấp hơn là Java.
Không chỉ có chúng tôi mang những mối bận tâm này. Sau nhiều năm với một bối cảnh khá im ắng về ngôn ngữ lập trình, Go nằm trong số những ngôn ngữ mới đầu tiên — Rust, Elixir, Swift và nhiều thứ khác — đã làm cho việc phát triển ngôn ngữ lập trình trở lại thành một lĩnh vực sôi động, gần như là dòng chính.
Go giải quyết những vấn đề này bằng cách cố gắng kết hợp sự dễ lập trình của một ngôn ngữ thông dịch, kiểu động với hiệu năng và tính an toàn của một ngôn ngữ kiểu tĩnh, biên dịch. Nó cũng nhắm tới việc thích ứng tốt hơn với phần cứng hiện đại, với hỗ trợ cho tính toán mạng và đa nhân. Cuối cùng, làm việc với Go phải nhanh: xây dựng một tệp thực thi lớn trên một máy tính đơn lẻ chỉ nên mất nhiều nhất vài giây. Việc đáp ứng các mục tiêu này khiến chúng tôi phải suy nghĩ lại một số cách tiếp cận lập trình từ các ngôn ngữ hiện có, dẫn tới: một hệ kiểu dựa trên tổ hợp thay vì phân cấp; hỗ trợ tính đồng thời và garbage collection; đặc tả chặt chẽ về các phụ thuộc; và vân vân. Những điều này không thể xử lý tốt bằng thư viện hay công cụ; cần tới một ngôn ngữ mới.
Bài viết Go at Google bàn về bối cảnh và động cơ đằng sau thiết kế của ngôn ngữ Go, đồng thời cung cấp thêm chi tiết cho nhiều câu trả lời trình bày trong FAQ này.
Tổ tiên của Go là những ngôn ngữ nào?
Go chủ yếu thuộc họ C (cú pháp cơ bản), với những đóng góp đáng kể từ họ Pascal/Modula/Oberon (khai báo, package), cộng thêm một số ý tưởng từ các ngôn ngữ lấy cảm hứng từ CSP của Tony Hoare, như Newsqueak và Limbo (tính đồng thời). Tuy nhiên, nó là một ngôn ngữ mới về mọi mặt. Ở mọi khía cạnh, ngôn ngữ được thiết kế bằng cách suy nghĩ về việc lập trình viên làm gì và làm sao để lập trình, ít nhất là kiểu lập trình mà chúng tôi làm, hiệu quả hơn, tức là vui hơn.
Các nguyên tắc dẫn đường trong thiết kế là gì?
Khi Go được thiết kế, Java và C++ là những ngôn ngữ được dùng phổ biến nhất để viết server, ít nhất là ở Google. Chúng tôi cảm thấy những ngôn ngữ đó đòi hỏi quá nhiều thủ tục sổ sách và lặp lại. Một số lập trình viên phản ứng bằng cách chuyển sang các ngôn ngữ động và uyển chuyển hơn như Python, đánh đổi bằng hiệu năng và an toàn kiểu. Chúng tôi cảm thấy hoàn toàn có thể có được hiệu năng, sự an toàn và sự uyển chuyển trong cùng một ngôn ngữ.
Go cố gắng giảm bớt lượng “gõ” theo cả hai nghĩa của từ này. Xuyên suốt thiết kế, chúng tôi đã cố giảm sự rườm rà và phức tạp. Không có khai báo tới trước, không có tệp header; mọi thứ được khai báo đúng một lần. Việc khởi tạo giàu tính biểu đạt, tự động và dễ dùng. Cú pháp gọn gàng và ít từ khoá. Sự lặp lại (foo.Foo* myFoo = new(foo.Foo)) được giảm bớt nhờ suy diễn kiểu đơn giản qua cấu trúc khai báo-và-khởi-tạo :=. Và có lẽ triệt để nhất là không hề có phân cấp kiểu: các kiểu chỉ đơn giản là tồn tại, chúng không phải công bố quan hệ giữa chúng với nhau. Những sự đơn giản hoá này cho phép Go vừa giàu tính biểu đạt vừa dễ hiểu mà không hy sinh năng suất.
Một nguyên tắc quan trọng khác là giữ cho các khái niệm trực giao với nhau. Method có thể được hiện thực cho bất kỳ kiểu nào; struct biểu diễn dữ liệu còn interface biểu diễn sự trừu tượng; và vân vân. Tính trực giao làm cho việc hiểu chuyện gì xảy ra khi mọi thứ kết hợp lại trở nên dễ dàng hơn.
Sử dụng
Google có dùng Go trong nội bộ không?
Có. Go được dùng rộng rãi trong môi trường chạy thật bên trong Google. Một ví dụ là máy chủ tải xuống của Google, dl.google.com, nơi phân phối các tệp nhị phân Chrome và những gói cài đặt lớn khác như các package apt-get.
Go không phải ngôn ngữ duy nhất được dùng ở Google, còn xa mới như vậy, nhưng nó là một ngôn ngữ chủ chốt cho một số lĩnh vực, bao gồm site reliability engineering (SRE) và xử lý dữ liệu quy mô lớn. Nó cũng là một phần chủ chốt của phần mềm vận hành Google Cloud.
Những công ty nào khác dùng Go?
Việc sử dụng Go đang tăng lên trên toàn thế giới, đặc biệt nhưng hoàn toàn không chỉ giới hạn trong lĩnh vực điện toán đám mây. Vài dự án hạ tầng đám mây lớn viết bằng Go là Docker và Kubernetes, nhưng còn nhiều hơn thế nữa.
Tuy nhiên không chỉ có đám mây, như bạn có thể thấy từ danh sách các công ty trên trang web go.dev cùng với một số câu chuyện thành công. Ngoài ra, Go Wiki có một trang, được cập nhật thường xuyên, liệt kê một phần trong số rất nhiều công ty đang dùng Go.
Wiki cũng có một trang với các liên kết tới thêm nhiều câu chuyện thành công về các công ty và dự án đang dùng ngôn ngữ này.
Chương trình Go có link được với chương trình C/C++ không?
Có thể dùng C và Go cùng nhau trong cùng một không gian địa chỉ, nhưng đó không phải sự kết hợp tự nhiên và có thể cần phần mềm interface đặc biệt. Ngoài ra, việc link C với mã Go sẽ đánh mất các tính chất an toàn bộ nhớ và quản lý stack mà Go cung cấp. Đôi khi việc dùng thư viện C để giải quyết một bài toán là hoàn toàn cần thiết, nhưng làm vậy luôn đưa vào một yếu tố rủi ro không có ở mã Go thuần, nên hãy làm một cách cẩn trọng.
Nếu bạn thực sự cần dùng C với Go, cách tiến hành phụ thuộc vào hiện thực compiler của Go. Compiler “chuẩn”, một phần của toolchain Go được nhóm Go tại Google hỗ trợ, có tên là gc. Ngoài ra còn có một compiler dựa trên GCC (gccgo) và một compiler dựa trên LLVM (gollvm), cùng với một danh sách ngày càng dài những compiler khác thường phục vụ các mục đích khác nhau, đôi khi chỉ hiện thực một tập con của ngôn ngữ, chẳng hạn TinyGo.
Gc dùng quy ước gọi hàm và linker khác với C, và do đó không thể được gọi trực tiếp từ chương trình C, hay ngược lại. Chương trình cgo cung cấp cơ chế cho một “foreign function interface” để cho phép gọi các thư viện C từ mã Go một cách an toàn. SWIG mở rộng khả năng này sang các thư viện C++.
Bạn cũng có thể dùng cgo và SWIG với gccgo và gollvm. Vì chúng dùng ABI truyền thống nên cũng có thể, với sự cẩn trọng cao độ, link mã từ các compiler này trực tiếp với các chương trình C hoặc C++ được biên dịch bằng GCC/LLVM. Tuy nhiên, làm vậy một cách an toàn đòi hỏi hiểu biết về quy ước gọi hàm của mọi ngôn ngữ liên quan, cũng như quan tâm tới giới hạn stack khi gọi C hoặc C++ từ Go.
Go hỗ trợ những IDE nào?
Dự án Go không bao gồm một IDE riêng, nhưng ngôn ngữ và các thư viện đã được thiết kế sao cho việc phân tích mã nguồn trở nên dễ dàng. Kết quả là hầu hết các trình soạn thảo và IDE nổi tiếng đều hỗ trợ Go tốt, hoặc trực tiếp hoặc thông qua plugin.
Nhóm Go cũng hỗ trợ một language server cho Go theo giao thức LSP, tên là gopls. Các công cụ hỗ trợ LSP có thể dùng gopls để tích hợp hỗ trợ đặc thù cho ngôn ngữ.
Danh sách các IDE và trình soạn thảo nổi tiếng hỗ trợ Go tốt bao gồm Emacs, Vim, VSCode, Atom, Eclipse, Sublime, IntelliJ (thông qua một biến thể riêng tên là GoLand), và nhiều nữa. Nhiều khả năng môi trường ưa thích của bạn cũng là một môi trường hiệu quả để lập trình bằng Go.
Go có hỗ trợ protocol buffers của Google không?
Một dự án mã nguồn mở riêng cung cấp plugin compiler và thư viện cần thiết. Nó có tại github.com/golang/protobuf/.
Thiết kế
Go có runtime không?
Go có một thư viện runtime đồ sộ, thường chỉ được gọi là runtime, là một phần của mọi chương trình Go. Thư viện này hiện thực garbage collection, tính đồng thời, quản lý stack và các tính năng thiết yếu khác của ngôn ngữ Go. Mặc dù giữ vai trò trung tâm hơn đối với ngôn ngữ, runtime của Go tương tự với libc, thư viện C.
Tuy nhiên, điều quan trọng cần hiểu là runtime của Go không bao gồm một máy ảo, như thứ mà Java runtime cung cấp. Chương trình Go được biên dịch trước thành mã máy native (hoặc JavaScript hoặc WebAssembly, với một số hiện thực biến thể). Do đó, mặc dù thuật ngữ này thường được dùng để mô tả môi trường ảo mà một chương trình chạy trong đó, trong Go từ “runtime” chỉ là tên đặt cho thư viện cung cấp các dịch vụ thiết yếu của ngôn ngữ.
Chuyện gì với định danh Unicode?
Khi thiết kế Go, chúng tôi muốn chắc chắn rằng nó không quá thiên về ASCII, điều đó có nghĩa là mở rộng không gian định danh ra khỏi giới hạn của ASCII 7-bit. Quy tắc của Go — ký tự trong định danh phải là chữ cái hoặc chữ số theo định nghĩa của Unicode — thì dễ hiểu và dễ hiện thực nhưng có những hạn chế. Chẳng hạn, các ký tự kết hợp (combining character) bị loại trừ theo thiết kế, và điều đó loại bỏ một số ngôn ngữ như Devanagari.
Quy tắc này còn một hệ quả đáng tiếc khác. Vì một định danh được export phải bắt đầu bằng chữ cái viết hoa, các định danh tạo từ ký tự của một số ngôn ngữ, theo định nghĩa, không thể được export. Hiện tại giải pháp duy nhất là dùng thứ gì đó như X日本語, điều rõ ràng là không thoả đáng.
Từ phiên bản sớm nhất của ngôn ngữ, đã có rất nhiều suy nghĩ về cách tốt nhất để mở rộng không gian định danh nhằm đáp ứng các lập trình viên dùng những ngôn ngữ bản địa khác. Chính xác phải làm gì vẫn còn là một chủ đề thảo luận sôi nổi, và một phiên bản tương lai của ngôn ngữ có thể sẽ rộng rãi hơn trong định nghĩa về định danh. Chẳng hạn, nó có thể tiếp thu một số ý tưởng từ các khuyến nghị của tổ chức Unicode dành cho định danh. Dù có chuyện gì xảy ra, việc đó phải được làm một cách tương thích, đồng thời giữ nguyên (hoặc có lẽ mở rộng) cách chữ hoa chữ thường quyết định tính nhìn thấy được của định danh, vốn vẫn là một trong những tính năng chúng tôi ưa thích nhất của Go.
Hiện thời, chúng tôi có một quy tắc đơn giản có thể mở rộng về sau mà không phá vỡ các chương trình, một quy tắc tránh được những lỗi chắc chắn sẽ phát sinh từ một quy tắc chấp nhận các định danh nhập nhằng.
Tại sao Go không có tính năng X?
Mọi ngôn ngữ đều chứa những tính năng mới lạ và bỏ sót tính năng ưa thích của ai đó. Go được thiết kế với con mắt hướng tới sự dễ chịu khi lập trình, tốc độ biên dịch, tính trực giao của các khái niệm, và nhu cầu hỗ trợ những tính năng như tính đồng thời và garbage collection. Tính năng ưa thích của bạn có thể vắng mặt vì nó không phù hợp, vì nó ảnh hưởng tới tốc độ biên dịch hoặc sự rõ ràng của thiết kế, hoặc vì nó sẽ làm cho mô hình hệ thống nền tảng trở nên quá khó khăn.
Nếu bạn thấy khó chịu vì Go thiếu tính năng X, xin hãy lượng thứ cho chúng tôi và tìm hiểu những tính năng mà Go có. Bạn có thể sẽ thấy chúng bù đắp một cách thú vị cho sự thiếu vắng của X.
Go có kiểu generic từ khi nào?
Bản phát hành Go 1.18 đã bổ sung type parameter vào ngôn ngữ. Điều này cho phép một dạng lập trình đa hình hay generic. Xem đặc tả ngôn ngữ và đề xuất để biết chi tiết.
Tại sao Go ban đầu được phát hành mà không có kiểu generic?
Go được dự tính làm một ngôn ngữ để viết các chương trình server sao cho dễ bảo trì theo thời gian. (Xem bài viết này để biết thêm bối cảnh.) Thiết kế tập trung vào những thứ như khả năng mở rộng, tính dễ đọc và tính đồng thời. Lập trình đa hình khi đó dường như không thiết yếu đối với các mục tiêu của ngôn ngữ, nên ban đầu đã được để ra ngoài cho đơn giản.
Generics thì tiện lợi nhưng phải trả giá bằng sự phức tạp trong hệ kiểu và lúc chạy. Phải mất một thời gian mới phát triển được một thiết kế mà chúng tôi tin là mang lại giá trị tương xứng với sự phức tạp đó.
Tại sao Go không có exception?
Chúng tôi tin rằng việc gắn exception với một cấu trúc điều khiển, như trong thành ngữ try-catch-finally, sẽ dẫn tới mã nguồn rối rắm. Nó cũng có xu hướng khuyến khích lập trình viên gán nhãn “ngoại lệ” cho quá nhiều lỗi thông thường, chẳng hạn như việc không mở được một tệp.
Go có cách tiếp cận khác. Với việc xử lý lỗi thông thường, cơ chế trả về nhiều giá trị của Go giúp dễ dàng báo lỗi mà không phải nhồi nhét thêm vào giá trị trả về. Một kiểu lỗi chuẩn mực, kết hợp với các tính năng khác của Go, làm cho việc xử lý lỗi trở nên dễ chịu nhưng khá khác biệt so với ở các ngôn ngữ khác.
Go cũng có vài hàm dựng sẵn để phát tín hiệu và phục hồi từ những tình huống thực sự ngoại lệ. Cơ chế phục hồi chỉ được thực thi như một phần của quá trình tháo dỡ trạng thái của một hàm sau khi có lỗi, điều này đủ để xử lý thảm hoạ nhưng không đòi hỏi thêm cấu trúc điều khiển nào, và khi dùng khéo, có thể cho ra mã xử lý lỗi gọn gàng.
Xem bài viết Defer, Panic, and Recover để biết chi tiết. Ngoài ra, bài blog Errors are values mô tả một cách tiếp cận để xử lý lỗi gọn gàng trong Go bằng cách cho thấy rằng, vì lỗi cũng chỉ là giá trị, toàn bộ sức mạnh của Go có thể được huy động vào việc xử lý lỗi.
Tại sao Go không có assertion?
Go không cung cấp assertion. Không thể phủ nhận chúng tiện lợi, nhưng kinh nghiệm của chúng tôi là lập trình viên dùng chúng như cái nạng để tránh phải suy nghĩ về việc xử lý và báo lỗi cho đúng cách. Xử lý lỗi đúng cách nghĩa là server tiếp tục hoạt động thay vì sập sau một lỗi không chí mạng. Báo lỗi đúng cách nghĩa là các lỗi được nêu trực tiếp và đúng trọng tâm, giúp lập trình viên khỏi phải diễn giải một vệt crash dài dằng dặc. Lỗi chính xác đặc biệt quan trọng khi người nhìn thấy lỗi lại không quen thuộc với mã nguồn.
Chúng tôi hiểu đây là một điểm gây tranh cãi. Có nhiều thứ trong ngôn ngữ Go và các thư viện của nó khác với thực hành hiện đại, đơn giản vì chúng tôi thấy đôi khi cũng đáng thử một cách tiếp cận khác.
Tại sao xây dựng tính đồng thời dựa trên các ý tưởng của CSP?
Tính đồng thời và lập trình đa luồng theo thời gian đã mang tiếng là khó. Chúng tôi tin điều này một phần là do các thiết kế phức tạp như pthreads và một phần do quá nhấn mạnh vào những chi tiết cấp thấp như mutex, biến điều kiện và rào bộ nhớ. Các interface ở mức cao hơn cho phép viết mã đơn giản hơn nhiều, kể cả khi bên dưới vẫn có mutex và những thứ tương tự.
Một trong những mô hình thành công nhất cho việc hỗ trợ tính đồng thời ở mức ngôn ngữ đến từ Communicating Sequential Processes, hay CSP, của Hoare. Occam và Erlang là hai ngôn ngữ nổi tiếng bắt nguồn từ CSP. Các nguyên thuỷ đồng thời của Go bắt nguồn từ một nhánh khác của cây gia phả, mà đóng góp chính là khái niệm mạnh mẽ về channel như những đối tượng first class. Kinh nghiệm với vài ngôn ngữ trước đó đã cho thấy mô hình CSP ăn khớp tốt với khuôn khổ một ngôn ngữ thủ tục.
Tại sao lại là goroutine thay vì thread?
Goroutine là một phần trong việc làm cho tính đồng thời dễ sử dụng. Ý tưởng này, vốn đã có từ lâu, là ghép kênh các hàm thực thi độc lập — coroutine — lên một tập các thread. Khi một coroutine bị chặn, chẳng hạn do gọi một system call chặn, runtime tự động chuyển các coroutine khác trên cùng thread của hệ điều hành sang một thread khác đang chạy được, để chúng không bị chặn. Lập trình viên không thấy gì trong số này, và đó chính là điểm mấu chốt. Kết quả, thứ mà chúng tôi gọi là goroutine, có thể rất rẻ: chúng có rất ít chi phí phụ ngoài bộ nhớ cho stack, vốn chỉ vài kilobyte.
Để stack nhỏ, runtime của Go dùng stack co giãn được, có giới hạn. Một goroutine vừa được tạo ra được cấp vài kilobyte, gần như luôn là đủ. Khi không đủ, runtime tự động tăng (và giảm) bộ nhớ lưu stack, cho phép nhiều goroutine cùng tồn tại trong một lượng bộ nhớ khiêm tốn. Chi phí CPU trung bình khoảng ba lệnh rẻ tiền cho mỗi lần gọi hàm. Việc tạo hàng trăm ngàn goroutine trong cùng một không gian địa chỉ là hoàn toàn khả thi. Nếu goroutine chỉ là thread, tài nguyên hệ thống sẽ cạn kiệt ở một con số nhỏ hơn nhiều.
Tại sao các thao tác trên map không được định nghĩa là nguyên tử?
Sau nhiều thảo luận, chúng tôi quyết định rằng cách dùng map điển hình không đòi hỏi truy cập an toàn từ nhiều goroutine, và trong những trường hợp cần thiết thì map nhiều khả năng là một phần của một cấu trúc dữ liệu hay phép tính lớn hơn vốn đã được đồng bộ rồi. Do đó, việc bắt mọi thao tác map phải giành lấy một mutex sẽ làm chậm phần lớn các chương trình mà chỉ thêm an toàn cho số ít. Tuy nhiên đây không phải quyết định dễ dàng, vì nó có nghĩa là truy cập map không được kiểm soát có thể làm sập chương trình.
Ngôn ngữ không loại trừ việc cập nhật map một cách nguyên tử. Khi cần, chẳng hạn khi chạy một chương trình không đáng tin cậy, hiện thực có thể khoá lẫn nhau việc truy cập map.
Truy cập map chỉ không an toàn khi đang có cập nhật. Miễn là mọi goroutine chỉ đọc — tra cứu phần tử trong map, kể cả duyệt qua nó bằng vòng lặp for range — và không thay đổi map bằng cách gán cho phần tử hay xoá phần tử, thì chúng có thể truy cập map đồng thời một cách an toàn mà không cần đồng bộ.
Để hỗ trợ dùng map cho đúng, một số hiện thực của ngôn ngữ có một cơ chế kiểm tra đặc biệt tự động báo cáo lúc chạy khi một map bị sửa đổi một cách không an toàn bởi việc thực thi đồng thời. Ngoài ra còn có một kiểu trong thư viện sync tên là sync.Map, hoạt động tốt với một số mẫu sử dụng nhất định như cache tĩnh, mặc dù nó không phù hợp để thay thế map dựng sẵn một cách tổng quát.
Các bạn có chấp nhận thay đổi ngôn ngữ do tôi đề xuất không?
Mọi người thường gợi ý cải tiến cho ngôn ngữ — mailing list chứa một lịch sử phong phú các cuộc thảo luận như vậy — nhưng rất ít trong số các thay đổi đó được chấp nhận.
Mặc dù Go là một dự án mã nguồn mở, ngôn ngữ và các thư viện được bảo vệ bởi một cam kết tương thích ngăn những thay đổi phá vỡ các chương trình hiện có, ít nhất là ở mức mã nguồn (thi thoảng chương trình có thể cần biên dịch lại để cập nhật). Nếu đề xuất của bạn vi phạm đặc tả Go 1 thì chúng tôi thậm chí không thể xem xét ý tưởng đó, bất kể nó hay tới đâu. Một bản phát hành lớn trong tương lai của Go có thể không tương thích với Go 1, nhưng các thảo luận về chủ đề đó mới chỉ bắt đầu và có một điều chắc chắn: sẽ có rất ít điểm không tương thích như vậy được đưa vào trong quá trình đó. Hơn nữa, cam kết tương thích khuyến khích chúng tôi cung cấp một lộ trình tự động để các chương trình cũ thích ứng nếu tình huống đó xảy ra.
Ngay cả khi đề xuất của bạn tương thích với đặc tả Go 1, nó vẫn có thể không đúng tinh thần của các mục tiêu thiết kế của Go. Bài viết Go at Google: Language Design in the Service of Software Engineering giải thích nguồn gốc của Go và động cơ đằng sau thiết kế của nó.
Kiểu dữ liệu
Go có phải ngôn ngữ hướng đối tượng không?
Vừa phải vừa không. Mặc dù Go có kiểu dữ liệu và method và cho phép một phong cách lập trình hướng đối tượng, nó không có phân cấp kiểu. Khái niệm “interface” trong Go đưa ra một cách tiếp cận khác mà chúng tôi tin là dễ dùng và ở một số mặt thì tổng quát hơn. Cũng có những cách để embedding kiểu này vào kiểu khác nhằm cung cấp thứ gì đó tương tự — nhưng không đồng nhất — với subclassing. Hơn nữa, method trong Go tổng quát hơn so với trong C++ hay Java: chúng có thể được định nghĩa cho bất kỳ loại dữ liệu nào, kể cả các kiểu dựng sẵn như số nguyên trần trụi, “unboxed”. Chúng không bị giới hạn ở struct (class).
Ngoài ra, việc thiếu vắng phân cấp kiểu làm cho “đối tượng” trong Go có cảm giác nhẹ nhàng hơn nhiều so với trong các ngôn ngữ như C++ hay Java.
Làm sao để có dynamic dispatch cho method?
Cách duy nhất để có method được điều phối động là thông qua một interface. Method trên một struct hay bất kỳ kiểu cụ thể nào khác luôn được phân giải tĩnh.
Tại sao không có kế thừa kiểu?
Lập trình hướng đối tượng, ít nhất là trong các ngôn ngữ nổi tiếng nhất, kéo theo quá nhiều bàn luận về quan hệ giữa các kiểu, những quan hệ mà thường có thể được suy ra tự động. Go có cách tiếp cận khác.
Thay vì bắt lập trình viên phải khai báo trước rằng hai kiểu có liên quan với nhau, trong Go một kiểu tự động thoả mãn bất kỳ interface nào đặc tả một tập con các method của nó. Ngoài việc giảm bớt thủ tục sổ sách, cách tiếp cận này có những lợi thế thực sự. Các kiểu có thể thoả mãn nhiều interface cùng lúc, mà không có sự phức tạp của đa kế thừa truyền thống. Interface có thể rất nhẹ — một interface với một hoặc thậm chí không method nào cũng có thể diễn đạt một khái niệm hữu ích. Interface có thể được thêm vào sau, nếu một ý tưởng mới xuất hiện hoặc để phục vụ kiểm thử — mà không cần chú thích gì vào các kiểu ban đầu. Vì không có quan hệ tường minh giữa các kiểu và các interface, nên không có phân cấp kiểu nào phải quản lý hay bàn luận.
Có thể dùng những ý tưởng này để xây dựng thứ gì đó tương tự với pipe của Unix nhưng an toàn kiểu. Chẳng hạn, hãy xem fmt.Fprintf cho phép in có định dạng ra bất kỳ đầu ra nào, không chỉ tệp, hay cách package bufio có thể hoàn toàn tách biệt khỏi I/O tệp, hay cách các package image sinh ra các tệp ảnh nén. Tất cả những ý tưởng ấy đều bắt nguồn từ một interface duy nhất (io.Writer) biểu diễn một method duy nhất (Write). Và đó mới chỉ là bề nổi. Các interface của Go có ảnh hưởng sâu sắc tới cách cấu trúc chương trình.
Cần một chút thời gian để làm quen, nhưng phong cách phụ thuộc kiểu ngầm định này là một trong những thứ đem lại năng suất nhất của Go.
Tại sao len là một hàm chứ không phải method?
Chúng tôi đã tranh luận về vấn đề này nhưng quyết định rằng hiện thực len và các hàm tương tự dưới dạng hàm là ổn trong thực tế và không làm phức tạp các câu hỏi về interface (theo nghĩa hệ kiểu của Go) của các kiểu cơ bản.
Tại sao Go không hỗ trợ overloading cho method và toán tử?
Việc điều phối method được đơn giản hoá nếu nó không phải khớp kiểu nữa. Kinh nghiệm với các ngôn ngữ khác cho chúng tôi biết rằng có nhiều method cùng tên nhưng khác chữ ký thì thi thoảng cũng hữu ích nhưng trong thực tế cũng có thể gây nhầm lẫn và mong manh. Chỉ khớp theo tên và đòi hỏi sự nhất quán về kiểu là một quyết định đơn giản hoá lớn trong hệ kiểu của Go.
Về operator overloading, nó có vẻ là sự tiện lợi hơn là một yêu cầu bắt buộc. Một lần nữa, không có nó thì mọi thứ đơn giản hơn.
Tại sao Go không có khai báo “implements”?
Một kiểu trong Go hiện thực một interface bằng cách hiện thực các method của interface đó, không gì hơn. Tính chất này cho phép định nghĩa và sử dụng interface mà không cần sửa đổi mã nguồn hiện có. Nó tạo ra một dạng structural typing thúc đẩy sự tách bạch mối quan tâm và cải thiện việc tái sử dụng mã, đồng thời giúp dễ dàng hơn trong việc xây dựng dựa trên những mẫu hình nảy sinh khi mã nguồn phát triển. Ngữ nghĩa của interface là một trong những lý do chính tạo nên cảm giác nhanh nhẹn, nhẹ nhàng của Go.
Xem câu hỏi về kế thừa kiểu để biết thêm chi tiết.
Làm sao tôi bảo đảm được kiểu của mình thoả mãn một interface?
Bạn có thể yêu cầu compiler kiểm tra rằng kiểu T hiện thực interface I bằng cách thử một phép gán dùng giá trị zero của T hoặc pointer tới T, tuỳ trường hợp:
type T struct{}
var _ I = T{} // Verify that T implements I.
var _ I = (*T)(nil) // Verify that *T implements I.
Nếu T (hoặc *T, tương ứng) không hiện thực I, sai sót sẽ bị bắt lúc biên dịch.
Nếu bạn muốn người dùng một interface phải khai báo tường minh rằng họ hiện thực nó, bạn có thể thêm vào method set của interface một method với tên mang tính mô tả. Ví dụ:
type Fooer interface {
Foo()
ImplementsFooer()
}
Khi đó một kiểu phải hiện thực method ImplementsFooer mới là một Fooer, qua đó ghi nhận rõ ràng điều này và công bố nó trong đầu ra của go doc.
type Bar struct{}
func (b Bar) ImplementsFooer() {}
func (b Bar) Foo() {}
Phần lớn mã nguồn không dùng tới những ràng buộc kiểu này, vì chúng hạn chế tính hữu dụng của ý tưởng interface. Tuy nhiên, đôi khi chúng cần thiết để giải quyết sự nhập nhằng giữa các interface tương tự nhau.
Tại sao kiểu T không thoả mãn interface Equal?
Hãy xét interface đơn giản này, biểu diễn một đối tượng có thể so sánh chính nó với một giá trị khác:
type Equaler interface {
Equal(Equaler) bool
}
và kiểu T này:
type T int
func (t T) Equal(u T) bool { return t == u } // does not satisfy Equaler
Khác với tình huống tương tự trong một số hệ kiểu đa hình, T không hiện thực Equaler. Kiểu của đối số của T.Equal là T, chứ không phải đúng theo nghĩa đen kiểu Equaler được yêu cầu.
Trong Go, hệ kiểu không tự nâng kiểu của đối số của Equal; đó là trách nhiệm của lập trình viên, như được minh hoạ bởi kiểu T2, kiểu này thì có hiện thực Equaler:
type T2 int
func (t T2) Equal(u Equaler) bool { return t == u.(T2) } // satisfies Equaler
Tuy vậy ngay cả điều này cũng không giống các hệ kiểu khác, bởi vì trong Go bất kỳ kiểu nào thoả mãn Equaler đều có thể được truyền làm đối số cho T2.Equal, và lúc chạy chúng ta phải kiểm tra rằng đối số có kiểu T2. Một số ngôn ngữ thu xếp để bảo đảm điều đó ngay lúc biên dịch.
Một ví dụ liên quan đi theo chiều ngược lại:
type Opener interface {
Open() Reader
}
func (t T3) Open() *os.File
Trong Go, T3 không thoả mãn Opener, mặc dù trong một ngôn ngữ khác thì có thể.
Dù đúng là hệ kiểu của Go làm ít việc hơn cho lập trình viên trong những trường hợp như vậy, việc không có subtyping làm cho các quy tắc về việc thoả mãn interface rất dễ phát biểu: tên và chữ ký của hàm có đúng y như của interface hay không? Quy tắc của Go cũng dễ hiện thực một cách hiệu quả. Chúng tôi cảm thấy những lợi ích này bù lại cho việc thiếu vắng sự nâng kiểu tự động.
Tôi có thể chuyển một []T thành []interface{} không?
Không trực tiếp được. Đặc tả ngôn ngữ không cho phép điều đó, vì hai kiểu này không có cùng cách biểu diễn trong bộ nhớ. Cần phải sao chép từng phần tử một sang slice đích. Ví dụ này chuyển một slice int thành một slice interface{}:
t := []int{1, 2, 3, 4}
s := make([]interface{}, len(t))
for i, v := range t {
s[i] = v
}
Tôi có thể chuyển []T1 thành []T2 nếu T1 và T2 có cùng kiểu nền không?
Dòng cuối cùng của đoạn mã mẫu này không biên dịch được.
type T1 int
type T2 int
var t1 T1
var x = T2(t1) // OK
var st1 []T1
var sx = ([]T2)(st1) // NOT OK
Trong Go, các kiểu gắn chặt với method, ở chỗ mọi kiểu có tên đều có một method set (có thể rỗng). Quy tắc chung là bạn có thể đổi tên của kiểu đang được chuyển đổi (và do đó có thể đổi method set của nó) nhưng bạn không thể đổi tên (và method set) của các phần tử của một kiểu tổ hợp. Go yêu cầu bạn phải tường minh về các phép chuyển kiểu.
Tại sao giá trị lỗi nil của tôi lại không bằng nil?
Bên dưới, interface được hiện thực bằng hai thành phần, một kiểu T và một giá trị V. V là một giá trị cụ thể như một int, struct hay pointer, không bao giờ là bản thân một interface, và có kiểu T. Chẳng hạn, nếu chúng ta lưu giá trị int 3 trong một interface, giá trị interface thu được sẽ, một cách sơ đồ, là (T=int, V=3). Giá trị V cũng được gọi là giá trị động của interface, vì một biến interface cho trước có thể giữ những giá trị V khác nhau (và các kiểu T tương ứng) trong quá trình chương trình thực thi.
Một giá trị interface là nil chỉ khi cả V và T đều chưa được đặt, (T=nil, V chưa được đặt). Nói riêng, một interface nil sẽ luôn giữ một kiểu nil. Nếu chúng ta lưu một pointer nil kiểu *int bên trong một giá trị interface, kiểu bên trong sẽ là *int bất kể giá trị của pointer là gì: (T=*int, V=nil). Do đó một giá trị interface như vậy sẽ khác nil ngay cả khi giá trị pointer V bên trong là nil.
Tình huống này có thể gây bối rối, và nảy sinh khi một giá trị nil được lưu bên trong một giá trị interface, chẳng hạn một giá trị trả về error:
func returnsError() error {
var p *MyError = nil
if bad() {
p = ErrBad
}
return p // Will always return a non-nil error.
}
Nếu mọi chuyện suôn sẻ, hàm trả về một p nil, nên giá trị trả về là một giá trị interface error giữ (T=*MyError, V=nil). Điều này nghĩa là nếu bên gọi so sánh lỗi trả về với nil, nó sẽ luôn trông như thể đã có lỗi, ngay cả khi chẳng có gì tệ xảy ra. Để trả về một error nil đúng nghĩa cho bên gọi, hàm phải trả về một nil tường minh:
func returnsError() error {
if bad() {
return ErrBad
}
return nil
}
Với các hàm trả về lỗi, việc luôn dùng kiểu error trong chữ ký của chúng (như chúng ta đã làm ở trên) thay vì một kiểu cụ thể như *MyError là một ý hay, vì nó giúp bảo đảm lỗi được tạo ra đúng cách. Ví dụ, os.Open trả về một error mặc dù, nếu không phải nil, nó luôn có kiểu cụ thể *os.PathError.
Những tình huống tương tự như mô tả ở đây có thể nảy sinh bất cứ khi nào interface được dùng. Chỉ cần nhớ rằng nếu có bất kỳ giá trị cụ thể nào đã được lưu trong interface, thì interface đó sẽ không phải nil. Để biết thêm, xem The Laws of Reflection.
Tại sao các kiểu có kích thước bằng không lại hành xử kỳ lạ?
Go hỗ trợ các kiểu có kích thước bằng không, chẳng hạn một struct không có trường nào (struct{}) hay một mảng không có phần tử nào ([0]byte). Bạn không thể lưu gì trong một kiểu kích thước bằng không, nhưng những kiểu này đôi khi hữu ích khi không cần tới giá trị, như trong map[int]struct{} hay một kiểu có method nhưng không có giá trị.
Các biến khác nhau có kiểu kích thước bằng không có thể được đặt tại cùng một vị trí trong bộ nhớ. Điều này an toàn vì không có giá trị nào có thể được lưu trong những biến đó.
Hơn nữa, ngôn ngữ không đưa ra bảo đảm nào về việc liệu pointer tới hai biến kích thước bằng không khác nhau có so sánh bằng nhau hay không. Những phép so sánh như vậy thậm chí có thể trả về true tại một điểm trong chương trình rồi trả về false tại một điểm khác, tuỳ thuộc chính xác vào cách chương trình được biên dịch và thực thi.
Một vấn đề riêng biệt với các kiểu kích thước bằng không là pointer tới một trường struct kích thước bằng không không được chồng lấn với pointer tới một đối tượng khác trong bộ nhớ. Điều đó có thể gây nhầm lẫn cho garbage collector. Nghĩa là nếu trường cuối cùng trong một struct có kích thước bằng không, struct đó sẽ được chèn thêm để bảo đảm pointer tới trường cuối không chồng lấn với vùng bộ nhớ nằm ngay sau struct. Do đó, chương trình này:
func main() {
type S struct {
f1 byte
f2 struct{}
}
fmt.Println(unsafe.Sizeof(S{}))
}
sẽ in ra 2, chứ không phải 1, trong hầu hết các hiện thực Go.
Tại sao không có union không gắn thẻ, như trong C?
Union không gắn thẻ sẽ vi phạm các bảo đảm về an toàn bộ nhớ của Go.
Tại sao Go không có kiểu variant?
Kiểu variant, còn gọi là kiểu đại số, cung cấp một cách để chỉ định rằng một giá trị có thể nhận một trong một tập các kiểu khác, nhưng chỉ những kiểu đó thôi. Một ví dụ phổ biến trong lập trình hệ thống là chỉ định rằng một lỗi là, chẳng hạn, lỗi mạng, lỗi bảo mật hay lỗi ứng dụng, và cho phép bên gọi phân biệt nguồn gốc của vấn đề bằng cách xem xét kiểu của lỗi. Một ví dụ khác là cây cú pháp trong đó mỗi nút có thể là một kiểu khác nhau: khai báo, câu lệnh, phép gán và vân vân.
Chúng tôi đã cân nhắc thêm kiểu variant vào Go, nhưng sau khi thảo luận đã quyết định bỏ chúng ra vì chúng chồng lấn theo những cách gây nhầm lẫn với interface. Chuyện gì sẽ xảy ra nếu các phần tử của một kiểu variant bản thân lại là interface?
Ngoài ra, một phần những gì kiểu variant giải quyết đã được ngôn ngữ bao quát rồi. Ví dụ về lỗi thì dễ diễn đạt bằng cách dùng một giá trị interface để giữ lỗi và một type switch để phân biệt các trường hợp. Ví dụ về cây cú pháp cũng làm được, dù không thanh nhã bằng.
Tại sao Go không có kiểu kết quả hiệp biến?
Kiểu kết quả hiệp biến (covariant result type) sẽ có nghĩa là một interface như
type Copyable interface {
Copy() interface{}
}
sẽ được thoả mãn bởi method
func (v Value) Copy() Value
vì Value hiện thực interface rỗng. Trong Go, kiểu của method phải khớp chính xác, nên Value không hiện thực Copyable. Go tách bạch khái niệm về việc một kiểu làm gì — các method của nó — khỏi hiện thực của kiểu đó. Nếu hai method trả về những kiểu khác nhau thì chúng không làm cùng một việc. Những lập trình viên muốn có kiểu kết quả hiệp biến thường đang cố diễn đạt một phân cấp kiểu thông qua interface. Trong Go, việc tách bạch rõ ràng giữa interface và hiện thực là tự nhiên hơn.
Giá trị
Vì sao Go không cung cấp chuyển đổi số ngầm định?
Sự tiện lợi của việc tự động chuyển đổi giữa các kiểu số trong C bị lấn át bởi sự rối rắm mà nó gây ra. Khi nào thì một biểu thức là unsigned? Giá trị lớn cỡ nào? Nó có tràn không? Kết quả có khả chuyển không, có độc lập với máy thực thi nó không? Nó cũng làm compiler phức tạp thêm; “các phép chuyển đổi số học thông thường” của C không dễ hiện thực và lại thiếu nhất quán giữa các kiến trúc. Vì lý do khả chuyển, chúng tôi quyết định làm mọi thứ rõ ràng và thẳng thắn, với cái giá là phải viết vài phép chuyển đổi tường minh trong mã nguồn. Tuy vậy, định nghĩa về hằng số trong Go—những giá trị có độ chính xác tuỳ ý, không mang chú thích về dấu hay kích thước—giúp cải thiện tình hình đáng kể.
Một chi tiết liên quan là, khác với C, int và int64 là hai kiểu dữ liệu khác nhau ngay cả khi int là kiểu 64-bit. Kiểu int mang tính tổng quát; nếu bạn quan tâm một số nguyên chứa bao nhiêu bit, Go khuyến khích bạn nói rõ điều đó.
Hằng số hoạt động như thế nào trong Go?
Mặc dù Go nghiêm ngặt về chuyển đổi giữa các biến thuộc các kiểu số khác nhau, hằng số trong ngôn ngữ lại linh hoạt hơn nhiều. Các hằng số dạng literal như 23, 3.14159 và math.Pi nằm trong một không gian số lý tưởng, với độ chính xác tuỳ ý và không tràn trên hay tràn dưới. Ví dụ, giá trị của math.Pi được ghi tới 63 chữ số thập phân trong mã nguồn, và các biểu thức hằng liên quan đến giá trị đó giữ được độ chính xác vượt quá những gì một float64 có thể chứa. Chỉ khi hằng số hoặc biểu thức hằng được gán cho một biến—một vị trí bộ nhớ trong chương trình—thì nó mới trở thành một con số “máy tính” với các thuộc tính và độ chính xác dấu phẩy động thông thường.
Ngoài ra, vì chúng chỉ là những con số chứ không phải giá trị có kiểu, hằng số trong Go có thể được dùng tự do hơn biến, nhờ đó làm dịu bớt phần nào sự gượng gạo quanh các quy tắc chuyển đổi nghiêm ngặt. Bạn có thể viết những biểu thức như
sqrt2 := math.Sqrt(2)
mà compiler không phàn nàn, bởi con số lý tưởng 2 có thể được chuyển đổi an toàn và chính xác sang float64 cho lời gọi math.Sqrt.
Bài viết blog mang tên Constants bàn về chủ đề này chi tiết hơn.
Vì sao map được dựng sẵn trong ngôn ngữ?
Cùng lý do với string: chúng là một cấu trúc dữ liệu mạnh mẽ và quan trọng đến mức việc cung cấp sẵn một hiện thực xuất sắc kèm hỗ trợ cú pháp khiến việc lập trình dễ chịu hơn. Chúng tôi tin rằng hiện thực map của Go đủ tốt để phục vụ tuyệt đại đa số nhu cầu sử dụng. Nếu một ứng dụng cụ thể có thể hưởng lợi từ một hiện thực riêng, bạn hoàn toàn có thể tự viết, nhưng nó sẽ không thuận tiện về mặt cú pháp; đây có vẻ là một đánh đổi hợp lý.
Vì sao map không cho phép dùng slice làm khoá?
Việc tra cứu trong map đòi hỏi toán tử so sánh bằng, thứ mà slice không hiện thực. Chúng không hiện thực phép so sánh bằng vì phép bằng không được định nghĩa rõ ràng trên những kiểu như vậy; có nhiều cân nhắc liên quan tới so sánh nông và so sánh sâu, so sánh con trỏ và so sánh giá trị, cách xử lý các kiểu đệ quy, v.v. Chúng tôi có thể xem xét lại vấn đề này—và việc hiện thực phép so sánh bằng cho slice sẽ không làm hỏng bất kỳ chương trình hiện có nào—nhưng khi chưa có một ý niệm rõ ràng về việc phép bằng của slice nên có nghĩa gì, để nó ra ngoài lúc này là đơn giản hơn.
Phép so sánh bằng có định nghĩa cho struct và mảng, nên chúng có thể dùng làm khoá của map.
Vì sao map, slice và channel là tham chiếu trong khi mảng là giá trị?
Chủ đề này có nhiều duyên nợ lịch sử. Thuở ban đầu, map và channel về mặt cú pháp là con trỏ, và không thể khai báo hay sử dụng một thực thể không phải con trỏ. Ngoài ra, chúng tôi đã loay hoay với việc mảng nên hoạt động thế nào. Cuối cùng chúng tôi quyết định rằng việc phân tách nghiêm ngặt giữa con trỏ và giá trị khiến ngôn ngữ khó dùng hơn. Việc đổi các kiểu này để chúng hành xử như tham chiếu tới cấu trúc dữ liệu chia sẻ tương ứng đã giải quyết các vấn đề đó. Thay đổi này thêm vào ngôn ngữ một chút phức tạp đáng tiếc, nhưng lại có tác động lớn tới tính dễ dùng: Go trở thành một ngôn ngữ năng suất và dễ chịu hơn nhiều kể từ khi nó được đưa vào.
Viết mã
Thư viện được viết tài liệu như thế nào?
Để truy cập tài liệu từ dòng lệnh, công cụ go có một lệnh con doc cung cấp giao diện văn bản cho tài liệu của các khai báo, tệp, package, v.v.
Trang khám phá package toàn cầu pkg.go.dev/pkg/. chạy một máy chủ trích xuất tài liệu package từ mã nguồn Go ở bất cứ đâu trên web và phục vụ nó dưới dạng HTML kèm link tới các khai báo và các thành phần liên quan. Đây là cách dễ nhất để tìm hiểu về các thư viện Go hiện có.
Thuở đầu của dự án, có một chương trình tương tự tên là godoc, cũng có thể chạy để trích xuất tài liệu cho các tệp trên máy cục bộ; pkg.go.dev/pkg/ về cơ bản là hậu duệ của nó. Một hậu duệ khác là lệnh pkgsite, giống godoc, có thể chạy cục bộ, mặc dù nó chưa được tích hợp vào kết quả mà go doc hiển thị.
Có tài liệu hướng dẫn phong cách lập trình Go không?
Không có tài liệu hướng dẫn phong cách chính thức nào, dù rõ ràng là tồn tại một “phong cách Go” dễ nhận ra.
Go đã thiết lập các quy ước để định hướng các quyết định về đặt tên, bố cục và tổ chức tệp. Tài liệu Effective Go chứa một số lời khuyên về các chủ đề này. Trực tiếp hơn, chương trình gofmt là một bộ in đẹp có mục đích áp đặt các quy tắc bố cục; nó thay thế cho tập hợp những điều nên và không nên thường thấy vốn để ngỏ cho diễn giải. Toàn bộ mã Go trong repository, và tuyệt đại đa số mã trong thế giới mã nguồn mở, đều đã được chạy qua gofmt.
Tài liệu mang tên Go Code Review Comments là một tập hợp các bài viết rất ngắn về các chi tiết của thành ngữ Go mà lập trình viên thường bỏ sót. Nó là tài liệu tham khảo tiện dụng cho những người review mã cho các dự án Go.
Làm sao để gửi patch cho các thư viện Go?
Mã nguồn thư viện nằm trong thư mục src của repository. Nếu bạn muốn thực hiện một thay đổi lớn, hãy thảo luận trên mailing list trước khi bắt tay vào làm.
Xem tài liệu Contributing to the Go project để biết thêm về cách tiến hành.
Vì sao “go get” dùng HTTPS khi clone một repository?
Các công ty thường chỉ cho phép lưu lượng đi ra trên các cổng TCP tiêu chuẩn 80 (HTTP) và 443 (HTTPS), chặn lưu lượng đi ra trên các cổng khác, bao gồm cổng TCP 9418 (git) và cổng TCP 22 (SSH). Khi dùng HTTPS thay vì HTTP, git mặc định bắt buộc xác thực chứng chỉ, qua đó bảo vệ trước các tấn công man-in-the-middle, nghe lén và giả mạo. Vì vậy lệnh go get dùng HTTPS cho an toàn.
Git có thể được cấu hình để xác thực qua HTTPS hoặc dùng SSH thay cho HTTPS. Để xác thực qua HTTPS, bạn có thể thêm một dòng vào tệp $HOME/.netrc mà git tra cứu:
machine github.com login *USERNAME* password *APIKEY*
Với tài khoản GitHub, mật khẩu có thể là một personal access token.
Git cũng có thể được cấu hình để dùng SSH thay cho HTTPS với các URL khớp một tiền tố cho trước. Ví dụ, để dùng SSH cho mọi truy cập GitHub, thêm các dòng sau vào ~/.gitconfig của bạn:
[url "ssh://[email protected]/"]
insteadOf = https://github.com/
Khi làm việc với module riêng tư nhưng dùng module proxy công khai cho các phụ thuộc, bạn có thể cần đặt GOPRIVATE. Xem private modules để biết chi tiết và các thiết lập bổ sung.
Tôi nên quản lý phiên bản package bằng “go get” như thế nào?
Bộ công cụ Go có sẵn một hệ thống quản lý các tập package liên quan theo phiên bản, gọi là module. Module được giới thiệu trong Go 1.11 và đã sẵn sàng cho môi trường sản xuất kể từ 1.14.
Để tạo một dự án dùng module, chạy go mod init. Lệnh này tạo tệp go.mod để theo dõi phiên bản các phụ thuộc.
go mod init example/project
Để thêm, nâng cấp hoặc hạ cấp một phụ thuộc, chạy go get:
go get golang.org/x/[email protected]
Xem Tutorial: Create a module để biết thêm về cách bắt đầu.
Xem Developing modules để có hướng dẫn quản lý phụ thuộc bằng module.
Các package bên trong module nên duy trì tương thích ngược khi chúng tiến hoá, theo quy tắc tương thích import:
Nếu một package cũ và một package mới có cùng import path,
package mới phải tương thích ngược với package cũ.
Cam kết tương thích Go 1 là tài liệu tham khảo tốt ở đây: đừng bỏ đi các tên đã export, khuyến khích dùng composite literal có gắn nhãn, v.v. Nếu cần một chức năng khác, hãy thêm một tên mới thay vì thay đổi tên cũ.
Module hệ thống hoá điều này bằng semantic versioning và semantic import versioning. Nếu buộc phải phá vỡ tương thích, hãy phát hành module ở một phiên bản major mới. Module từ phiên bản major 2 trở lên đòi hỏi một hậu tố phiên bản major như một phần của đường dẫn (ví dụ /v2). Điều này bảo toàn quy tắc tương thích import: các package ở các phiên bản major khác nhau của một module có đường dẫn khác nhau.
Con trỏ và cấp phát
Khi nào tham số của hàm được truyền theo giá trị?
Như trong mọi ngôn ngữ thuộc họ C, mọi thứ trong Go đều được truyền theo giá trị. Nghĩa là, một hàm luôn nhận được một bản sao của thứ được truyền vào, cứ như thể có một câu lệnh gán, gán giá trị đó cho tham số. Ví dụ, truyền một giá trị int vào hàm sẽ tạo một bản sao của int đó, còn truyền một giá trị con trỏ sẽ tạo một bản sao của con trỏ, nhưng không sao chép dữ liệu mà nó trỏ tới. (Xem mục sau để có thảo luận về việc điều này ảnh hưởng thế nào tới receiver của method.)
Giá trị map và slice hành xử như con trỏ: chúng là các bộ mô tả chứa con trỏ tới dữ liệu map hoặc slice bên dưới. Sao chép một giá trị map hoặc slice không sao chép dữ liệu mà nó trỏ tới. Sao chép một giá trị interface tạo ra bản sao của thứ được lưu trong giá trị interface đó. Nếu giá trị interface chứa một struct, sao chép giá trị interface sẽ tạo bản sao của struct đó. Nếu giá trị interface chứa một con trỏ, sao chép giá trị interface sẽ tạo bản sao của con trỏ, nhưng một lần nữa không sao chép dữ liệu mà nó trỏ tới.
Lưu ý rằng thảo luận này nói về ngữ nghĩa của các thao tác. Các hiện thực thực tế có thể áp dụng tối ưu hoá để tránh việc sao chép, miễn là các tối ưu hoá đó không làm thay đổi ngữ nghĩa.
Khi nào tôi nên dùng con trỏ tới một interface?
Gần như không bao giờ. Con trỏ tới giá trị interface chỉ xuất hiện trong những tình huống hiếm hoi, hóc búa liên quan tới việc nguỵ trang kiểu của một giá trị interface để hoãn việc đánh giá.
Một lỗi phổ biến là truyền con trỏ tới một giá trị interface vào hàm vốn mong đợi một interface. Compiler sẽ báo lỗi này, nhưng tình huống vẫn có thể gây bối rối, vì đôi khi con trỏ là cần thiết để thoả mãn một interface. Điểm mấu chốt là dù một con trỏ tới kiểu cụ thể có thể thoả mãn một interface, nhưng với một ngoại lệ, một con trỏ tới interface không bao giờ có thể thoả mãn một interface.
Xét khai báo biến,
var w io.Writer
Hàm in fmt.Fprintf nhận đối số đầu tiên là một giá trị thoả mãn io.Writer—thứ gì đó hiện thực method Write chuẩn mực. Vì vậy ta có thể viết
fmt.Fprintf(w, "hello, world\n")
Tuy nhiên nếu ta truyền địa chỉ của w, chương trình sẽ không biên dịch được.
fmt.Fprintf(&w, "hello, world\n") // Compile-time error.
Ngoại lệ duy nhất là bất kỳ giá trị nào, kể cả con trỏ tới một interface, đều có thể được gán cho một biến thuộc kiểu interface rỗng (interface{}). Ngay cả vậy, gần như chắc chắn là sai lầm nếu giá trị đó là con trỏ tới một interface; kết quả có thể gây bối rối.
Tôi nên định nghĩa method trên giá trị hay trên con trỏ?
func (s *MyStruct) pointerMethod() { } // method on pointer
func (s MyStruct) valueMethod() { } // method on value
Với những lập trình viên chưa quen với con trỏ, sự phân biệt giữa hai ví dụ này có thể gây bối rối, nhưng tình huống thực ra rất đơn giản. Khi định nghĩa một method trên một kiểu, receiver (s trong các ví dụ trên) hành xử y hệt như thể nó là một đối số của method. Việc định nghĩa receiver là giá trị hay con trỏ, do đó, cũng chính là câu hỏi liệu một đối số hàm nên là giá trị hay con trỏ. Có vài điều cần cân nhắc.
Thứ nhất, và quan trọng nhất, method có cần sửa đổi receiver không? Nếu có, receiver bắt buộc phải là con trỏ. (Slice và map hành xử như tham chiếu, nên câu chuyện của chúng tinh tế hơn một chút, nhưng chẳng hạn để thay đổi độ dài của một slice trong một method thì receiver vẫn phải là con trỏ.) Trong các ví dụ trên, nếu pointerMethod sửa đổi các trường của s, bên gọi sẽ thấy những thay đổi đó, còn valueMethod được gọi với một bản sao đối số của bên gọi (đó chính là định nghĩa của truyền theo giá trị), nên những thay đổi nó thực hiện sẽ không hiện ra với bên gọi.
Nhân tiện, trong Java receiver của method luôn luôn là con trỏ, dù bản chất con trỏ của chúng được nguỵ trang phần nào (và các phát triển gần đây đang đưa value receiver vào Java). Chính value receiver trong Go mới là thứ khác thường.
Thứ hai là cân nhắc về hiệu quả. Nếu receiver lớn, chẳng hạn một struct đồ sộ, dùng pointer receiver có thể rẻ hơn.
Kế đến là tính nhất quán. Nếu một số method của kiểu buộc phải có pointer receiver, thì phần còn lại cũng nên như vậy, để method set nhất quán bất kể kiểu đó được dùng ra sao. Xem mục về method set để biết chi tiết.
Với các kiểu như kiểu cơ bản, slice và struct nhỏ, value receiver rất rẻ nên trừ khi ngữ nghĩa của method đòi hỏi con trỏ, value receiver vừa hiệu quả vừa rõ ràng.
new và make khác nhau thế nào?
Nói ngắn gọn: new cấp phát bộ nhớ, còn make khởi tạo các kiểu slice, map và channel.
Xem mục liên quan trong Effective Go để biết thêm chi tiết.
Kích thước của int trên máy 64 bit là bao nhiêu?
Kích thước của int và uint phụ thuộc vào hiện thực nhưng giống nhau trên một nền tảng cho trước. Vì tính khả chuyển, mã nguồn phụ thuộc vào một kích thước giá trị cụ thể nên dùng kiểu có kích thước tường minh, như int64. Trên máy 32-bit, các compiler mặc định dùng số nguyên 32-bit, còn trên máy 64-bit số nguyên có 64 bit. (Về mặt lịch sử, điều này không phải lúc nào cũng đúng.)
Mặt khác, các kiểu vô hướng dấu phẩy động và số phức luôn có kích thước xác định (không có kiểu cơ bản float hay complex), vì lập trình viên nên ý thức về độ chính xác khi dùng số dấu phẩy động. Kiểu mặc định dùng cho một hằng dấu phẩy động (không kiểu) là float64. Do đó foo := 3.0 khai báo một biến foo thuộc kiểu float64. Với một biến float32 được khởi tạo bằng một hằng (không kiểu), kiểu của biến phải được chỉ định tường minh trong khai báo biến:
var foo float32 = 3.0
Cách khác là gán kiểu cho hằng số bằng một phép chuyển đổi, như foo := float32(3.0).
Làm sao tôi biết một biến được cấp phát trên heap hay stack?
Về mặt tính đúng đắn, bạn không cần biết. Mỗi biến trong Go tồn tại chừng nào còn tham chiếu tới nó. Vị trí lưu trữ mà hiện thực chọn không liên quan gì tới ngữ nghĩa của ngôn ngữ.
Vị trí lưu trữ quả thật có ảnh hưởng tới việc viết chương trình hiệu quả. Khi có thể, các compiler Go sẽ cấp phát các biến cục bộ của một hàm trong stack frame của hàm đó. Tuy nhiên, nếu compiler không chứng minh được rằng biến đó không còn được tham chiếu sau khi hàm trả về, thì compiler buộc phải cấp phát biến đó trên heap được garbage collector quản lý để tránh lỗi con trỏ treo. Ngoài ra, nếu một biến cục bộ rất lớn, có thể hợp lý hơn khi lưu nó trên heap thay vì stack.
Trong các compiler hiện nay, nếu một biến bị lấy địa chỉ, biến đó trở thành ứng viên để cấp phát trên heap. Tuy nhiên, một escape analysis cơ bản nhận ra một số trường hợp khi các biến như vậy sẽ không sống quá thời điểm hàm trả về và có thể nằm trên stack.
Vì sao tiến trình Go của tôi dùng nhiều bộ nhớ ảo đến thế?
Bộ cấp phát bộ nhớ của Go dành riêng một vùng bộ nhớ ảo lớn làm đấu trường cho các cấp phát. Bộ nhớ ảo này cục bộ với tiến trình Go cụ thể đó; việc dành riêng này không tước bộ nhớ của các tiến trình khác.
Để biết lượng bộ nhớ thực tế được cấp cho một tiến trình Go, dùng lệnh Unix top và xem cột RES (Linux) hoặc RSIZE (macOS).
Tính đồng thời
Những thao tác nào là nguyên tử? Còn mutex thì sao?
Mô tả về tính nguyên tử của các thao tác trong Go có thể tìm thấy trong tài liệu Go Memory Model.
Các nguyên thuỷ đồng bộ và nguyên tử ở mức thấp có trong các package sync và sync/atomic. Những package này phù hợp cho các tác vụ đơn giản như tăng bộ đếm tham chiếu hoặc bảo đảm loại trừ tương hỗ ở quy mô nhỏ.
Với các thao tác ở mức cao hơn, chẳng hạn phối hợp giữa các máy chủ chạy đồng thời, các kỹ thuật ở mức cao hơn có thể dẫn tới chương trình đẹp hơn, và Go hỗ trợ cách tiếp cận này thông qua goroutine và channel của mình. Ví dụ, bạn có thể cấu trúc chương trình sao cho tại mỗi thời điểm chỉ có một goroutine chịu trách nhiệm cho một mẩu dữ liệu nhất định. Cách tiếp cận đó được tóm gọn trong Go proverb nguyên bản,
Đừng giao tiếp bằng cách chia sẻ bộ nhớ. Thay vào đó, hãy chia sẻ bộ nhớ bằng cách giao tiếp.
Xem code walk Share Memory By Communicating và bài viết đi kèm để có thảo luận chi tiết về khái niệm này.
Các chương trình đồng thời lớn nhiều khả năng sẽ vay mượn từ cả hai bộ công cụ này.
Vì sao chương trình của tôi không chạy nhanh hơn khi có nhiều CPU hơn?
Việc một chương trình có chạy nhanh hơn với nhiều CPU hơn hay không phụ thuộc vào bài toán mà nó đang giải. Ngôn ngữ Go cung cấp các nguyên thuỷ đồng thời, như goroutine và channel, nhưng tính đồng thời chỉ mở ra tính song song khi bài toán bên dưới tự nó vốn song song. Những bài toán vốn tuần tự không thể tăng tốc bằng cách thêm CPU, trong khi những bài toán có thể chia thành các mẩu thực thi song song thì có thể tăng tốc, đôi khi rất ngoạn mục.
Đôi khi thêm CPU lại làm chương trình chậm đi. Trên thực tế, những chương trình dành nhiều thời gian đồng bộ hoặc giao tiếp hơn là tính toán có ích có thể bị suy giảm hiệu năng khi dùng nhiều luồng OS. Đó là vì việc truyền dữ liệu giữa các luồng liên quan tới chuyển đổi ngữ cảnh, vốn có chi phí đáng kể, và chi phí đó có thể tăng khi có nhiều CPU hơn. Ví dụ, ví dụ sàng số nguyên tố trong đặc tả Go không có tính song song đáng kể dù nó khởi chạy nhiều goroutine; tăng số luồng (CPU) nhiều khả năng làm nó chậm đi hơn là nhanh lên.
Để biết chi tiết hơn về chủ đề này, xem bài nói mang tên Concurrency is not Parallelism.
Làm sao tôi kiểm soát số lượng CPU?
Số CPU khả dụng đồng thời cho các goroutine đang thực thi được kiểm soát bởi biến môi trường shell GOMAXPROCS, với giá trị mặc định là số lõi CPU khả dụng. Vì vậy các chương trình có tiềm năng thực thi song song sẽ đạt được điều đó theo mặc định trên máy nhiều CPU. Để thay đổi số CPU song song được dùng, đặt biến môi trường hoặc dùng hàm cùng tên của package runtime để cấu hình phần hỗ trợ lúc chạy sử dụng số luồng khác. Đặt nó bằng 1 sẽ loại bỏ khả năng có song song thực sự, buộc các goroutine độc lập phải thay phiên nhau thực thi.
Runtime có thể cấp phát nhiều luồng hơn giá trị của GOMAXPROCS để phục vụ nhiều yêu cầu I/O đang chờ. GOMAXPROCS chỉ ảnh hưởng tới số goroutine có thể thực sự thực thi cùng lúc; số goroutine bị chặn trong các lời gọi hệ thống có thể nhiều tuỳ ý.
Scheduler goroutine của Go làm tốt việc cân bằng goroutine và luồng, thậm chí có thể chiếm quyền thực thi của một goroutine để bảo đảm những goroutine khác trên cùng luồng không bị bỏ đói. Tuy nhiên, nó không hoàn hảo. Nếu bạn gặp vấn đề về hiệu năng, việc đặt GOMAXPROCS riêng cho từng ứng dụng có thể giúp ích.
Vì sao không có ID cho goroutine?
Goroutine không có tên; chúng chỉ là những công nhân vô danh. Chúng không phơi bày định danh, tên hay cấu trúc dữ liệu duy nhất nào cho lập trình viên. Một số người ngạc nhiên về điều này, họ mong đợi câu lệnh go trả về một thứ gì đó có thể dùng để truy cập và điều khiển goroutine về sau.
Lý do căn bản khiến goroutine vô danh là để toàn bộ ngôn ngữ Go vẫn khả dụng khi lập trình mã đồng thời. Ngược lại, các mẫu sử dụng phát sinh khi luồng và goroutine được đặt tên có thể hạn chế những gì một thư viện dùng chúng có thể làm.
Đây là một minh hoạ cho các khó khăn đó. Một khi bạn đặt tên cho một goroutine và dựng một mô hình quanh nó, nó trở nên đặc biệt, và bạn bị cám dỗ gắn mọi tính toán với goroutine ấy, bỏ qua khả năng dùng nhiều goroutine, có thể chia sẻ, cho việc xử lý. Nếu package net/http gắn trạng thái theo từng yêu cầu với một goroutine, các client sẽ không thể dùng thêm goroutine khi phục vụ một yêu cầu.
Hơn nữa, kinh nghiệm với các thư viện như thư viện cho hệ thống đồ hoạ vốn đòi hỏi mọi xử lý phải diễn ra trên “luồng chính” đã cho thấy cách tiếp cận đó gượng gạo và hạn chế đến mức nào khi triển khai trong một ngôn ngữ đồng thời. Chính sự tồn tại của một luồng hay goroutine đặc biệt buộc lập trình viên phải bóp méo chương trình để tránh sập và các vấn đề khác gây ra bởi việc vô tình thao tác trên sai luồng.
Với những trường hợp mà một goroutine cụ thể thực sự đặc biệt, ngôn ngữ cung cấp các tính năng như channel, có thể dùng theo nhiều cách linh hoạt để tương tác với nó.
Hàm và method
Vì sao T và *T có method set khác nhau?
Như đặc tả Go nói, method set của một kiểu T gồm mọi method có receiver kiểu T, trong khi method set của kiểu con trỏ tương ứng *T gồm mọi method có receiver *T hoặc T. Nghĩa là method set của *T bao hàm method set của T, nhưng không ngược lại.
Sự phân biệt này nảy sinh vì nếu một giá trị interface chứa con trỏ *T, một lời gọi method có thể lấy được giá trị bằng cách giải tham chiếu con trỏ, nhưng nếu một giá trị interface chứa một giá trị T, thì không có cách an toàn nào để một lời gọi method lấy được con trỏ. (Làm vậy sẽ cho phép một method sửa đổi nội dung của giá trị bên trong interface, điều mà đặc tả ngôn ngữ không cho phép.)
Ngay cả trong những trường hợp compiler có thể lấy địa chỉ của một giá trị để truyền cho method, nếu method sửa đổi giá trị đó thì các thay đổi sẽ bị mất đối với bên gọi.
Ví dụ, nếu đoạn mã dưới đây là hợp lệ:
var buf bytes.Buffer
io.Copy(buf, os.Stdin)
thì nó sẽ sao chép đầu vào chuẩn vào một bản sao của buf, chứ không vào chính buf. Đây gần như không bao giờ là hành vi mong muốn nên ngôn ngữ cấm điều đó.
Điều gì xảy ra với closure chạy dưới dạng goroutine?
Do cách hoạt động của biến vòng lặp, trước phiên bản Go 1.22 (xem cuối mục này để biết cập nhật), có thể phát sinh chút bối rối khi dùng closure cùng tính đồng thời. Xét chương trình sau:
func main() {
done := make(chan bool)
values := []string{"a", "b", "c"}
for _, v := range values {
go func() {
fmt.Println(v)
done <- true
}()
}
// wait for all goroutines to complete before exiting
for _ = range values {
<-done
}
}
Người ta có thể lầm tưởng đầu ra sẽ là a, b, c. Thứ bạn nhiều khả năng thấy lại là c, c, c. Đó là vì mỗi lần lặp của vòng lặp dùng cùng một thực thể của biến v, nên mỗi closure chia sẻ chung biến duy nhất đó. Khi closure chạy, nó in ra giá trị của v tại thời điểm fmt.Println được thực thi, nhưng v có thể đã bị sửa đổi kể từ khi goroutine được khởi chạy. Để giúp phát hiện điều này và các vấn đề khác trước khi chúng xảy ra, hãy chạy go vet.
Để gắn giá trị hiện tại của v vào mỗi closure khi nó được khởi chạy, bạn phải sửa vòng lặp bên trong để tạo một biến mới ở mỗi lần lặp. Một cách là truyền biến đó làm đối số cho closure:
for _, v := range values {
go func(u string) {
fmt.Println(u)
done <- true
}(v)
}
Trong ví dụ này, giá trị của v được truyền làm đối số cho hàm ẩn danh. Giá trị đó sau đó truy cập được bên trong hàm dưới dạng biến u.
Dễ hơn nữa là chỉ cần tạo một biến mới, dùng kiểu khai báo trông có vẻ lạ nhưng hoạt động tốt trong Go:
for _, v := range values {
v := v // create a new 'v'.
go func() {
fmt.Println(v)
done <- true
}()
}
Hành vi này của ngôn ngữ, tức không định nghĩa một biến mới cho mỗi lần lặp, nhìn lại thì bị xem là một sai lầm, và đã được khắc phục trong Go 1.22, phiên bản quả thực tạo một biến mới cho mỗi lần lặp, loại bỏ vấn đề này.
Luồng điều khiển
Vì sao Go không có toán tử ?:?
Không có phép kiểm tra ba ngôi trong Go. Bạn có thể dùng cách sau để đạt cùng kết quả:
if expr {
n = trueVal
} else {
n = falseVal
}
Lý do ?: vắng mặt trong Go là những người thiết kế ngôn ngữ đã chứng kiến thao tác này bị dùng quá thường xuyên để tạo ra những biểu thức phức tạp không thể xuyên thủng. Dạng if-else, dù dài hơn, rõ ràng hơn hẳn. Một ngôn ngữ chỉ cần một cấu trúc điều khiển luồng có điều kiện.
Type Parameter
Vì sao Go có type parameter?
Type parameter cho phép cái được gọi là lập trình tổng quát, trong đó hàm và cấu trúc dữ liệu được định nghĩa theo các kiểu dữ liệu được chỉ định về sau, khi những hàm và cấu trúc dữ liệu đó được dùng. Ví dụ, chúng cho phép viết một hàm trả về giá trị nhỏ hơn trong hai giá trị thuộc bất kỳ kiểu có thứ tự nào, mà không phải viết một phiên bản riêng cho từng kiểu khả dĩ. Để có giải thích sâu hơn kèm ví dụ, xem bài blog Why Generics?.
Generics được hiện thực thế nào trong Go?
Compiler có thể chọn biên dịch riêng từng lần khởi tạo hay biên dịch những lần khởi tạo tương tự nhau thành một hiện thực duy nhất. Cách tiếp cận một hiện thực duy nhất tương tự như một hàm có tham số là interface. Các compiler khác nhau sẽ có lựa chọn khác nhau cho các trường hợp khác nhau. Compiler Go tiêu chuẩn thông thường phát sinh một lần khởi tạo duy nhất cho mọi đối số kiểu có cùng hình dạng, trong đó hình dạng được xác định bởi các thuộc tính của kiểu như kích thước và vị trí các con trỏ mà nó chứa. Các bản phát hành tương lai có thể thử nghiệm với đánh đổi giữa thời gian biên dịch, hiệu quả lúc chạy và kích thước mã.
Generics trong Go so với generics trong các ngôn ngữ khác thế nào?
Chức năng cơ bản trong mọi ngôn ngữ đều tương tự nhau: có thể viết các kiểu và hàm dùng những kiểu được chỉ định về sau. Dù vậy, vẫn có một số khác biệt.
-
Java
Trong Java, compiler kiểm tra các kiểu generic lúc biên dịch nhưng loại bỏ các kiểu đó lúc chạy. Điều này được gọi là type erasure. Ví dụ, một kiểu Java được biết là
List<Integer>lúc biên dịch sẽ trở thành kiểu không genericListlúc chạy. Điều này nghĩa là, chẳng hạn, khi dùng dạng phản chiếu kiểu của Java thì không thể phân biệt một giá trị kiểuList<Integer>với một giá trị kiểuList<Float>. Trong Go, thông tin phản chiếu cho một kiểu generic bao gồm đầy đủ thông tin kiểu lúc biên dịch.Java dùng các ký tự đại diện kiểu như
List<? extends Number>hoặcList<? super Number>để hiện thực tính hiệp biến và nghịch biến cho generic. Go không có những khái niệm này, điều đó làm kiểu generic trong Go đơn giản hơn nhiều. -
C++
Theo truyền thống, template của C++ không áp đặt ràng buộc nào lên đối số kiểu, mặc dù C++20 hỗ trợ ràng buộc tuỳ chọn thông qua concepts. Trong Go, type constraint là bắt buộc với mọi type parameter. Concepts của C++20 được biểu diễn dưới dạng các mẩu mã nhỏ phải biên dịch được với các đối số kiểu. Type constraint của Go là các kiểu interface định nghĩa tập hợp mọi đối số kiểu được phép.
C++ hỗ trợ lập trình siêu dữ liệu với template; Go thì không. Trên thực tế, mọi compiler C++ đều biên dịch từng template tại điểm nó được khởi tạo; như đã nói ở trên, Go có thể và thực sự dùng các cách tiếp cận khác nhau cho các lần khởi tạo khác nhau.
-
Rust
Phiên bản ràng buộc của Rust được gọi là trait bound. Trong Rust, mối liên hệ giữa một trait bound và một kiểu phải được định nghĩa tường minh, hoặc trong crate định nghĩa trait bound đó hoặc trong crate định nghĩa kiểu đó. Trong Go, các đối số kiểu thoả mãn type constraint một cách ngầm định, giống như các kiểu Go hiện thực các kiểu interface một cách ngầm định. Thư viện chuẩn của Rust định nghĩa các trait tiêu chuẩn cho các thao tác như so sánh hay cộng; thư viện chuẩn của Go thì không, vì những thứ này có thể được biểu diễn trong mã người dùng thông qua kiểu interface. Ngoại lệ duy nhất là interface định nghĩa sẵn
comparablecủa Go, thứ nắm bắt một thuộc tính không biểu diễn được trong hệ thống kiểu. -
Python
Python không phải ngôn ngữ định kiểu tĩnh, nên có thể nói một cách hợp lý rằng mọi hàm Python luôn generic theo mặc định: chúng luôn có thể được gọi với giá trị thuộc bất kỳ kiểu nào, và mọi lỗi kiểu đều được phát hiện lúc chạy.
Vì sao Go dùng dấu ngoặc vuông cho danh sách type parameter?
Java và C++ dùng dấu ngoặc nhọn cho danh sách type parameter, như List<Integer> trong Java và std::vector<int> trong C++. Tuy nhiên, lựa chọn đó không khả dĩ với Go, vì nó dẫn tới một vấn đề cú pháp: khi phân tích mã bên trong một hàm, chẳng hạn v := F<T>, tại thời điểm nhìn thấy < thì nhập nhằng giữa việc ta đang thấy một lần khởi tạo hay một biểu thức dùng toán tử <. Điều này rất khó giải quyết nếu không có thông tin kiểu.
Ví dụ, xét một câu lệnh như
a, b = w < x, y > (z)
Không có thông tin kiểu thì không thể quyết định được liệu vế phải của phép gán là một cặp biểu thức (w < x và y > z), hay là một lần khởi tạo hàm generic kèm lời gọi trả về hai giá trị kết quả ((w<x, y>)(z)).
Một quyết định thiết kế cốt lõi của Go là việc phân tích cú pháp phải khả thi mà không cần thông tin kiểu, điều dường như bất khả thi khi dùng dấu ngoặc nhọn cho generics.
Go không phải là ngôn ngữ duy nhất hay đầu tiên dùng dấu ngoặc vuông; có những ngôn ngữ khác như Scala cũng dùng dấu ngoặc vuông cho mã generic.
Vì sao Go không hỗ trợ method có type parameter?
Go cho phép một kiểu generic có method, nhưng, ngoài receiver ra, các đối số của những method đó không thể dùng kiểu được tham số hoá. Chúng tôi không dự tính Go sẽ bao giờ thêm generic method.
Vấn đề nằm ở cách hiện thực chúng. Cụ thể, xét việc kiểm tra xem một giá trị trong một interface có hiện thực một interface khác với các method bổ sung hay không. Ví dụ, xét kiểu này, một struct rỗng với một method generic Nop trả về đối số của nó, cho mọi kiểu khả dĩ:
type Empty struct{}
func (Empty) Nop[T any](x T) T {
return x
}
Bây giờ giả sử một giá trị Empty được lưu trong một any và truyền cho đoạn mã khác kiểm tra xem nó làm được gì:
func TryNops(x any) {
if x, ok := x.(interface{ Nop(string) string }); ok {
fmt.Printf("string %s\n", x.Nop("hello"))
}
if x, ok := x.(interface{ Nop(int) int }); ok {
fmt.Printf("int %d\n", x.Nop(42))
}
if x, ok := x.(interface{ Nop(io.Reader) io.Reader }); ok {
data, err := io.ReadAll(x.Nop(strings.NewReader("hello world")))
fmt.Printf("reader %q %v\n", data, err)
}
}
Đoạn mã đó hoạt động ra sao nếu x là một Empty? Có vẻ như x phải thoả mãn cả ba phép kiểm tra, cùng với mọi dạng khác với mọi kiểu khác.
Mã nào sẽ chạy khi những method đó được gọi? Với method không generic, compiler sinh mã cho mọi hiện thực method và liên kết chúng vào chương trình cuối cùng. Nhưng với method generic, có thể có vô số hiện thực method, nên cần một chiến lược khác.
Có bốn lựa chọn:
-
Lúc liên kết, lập danh sách mọi phép kiểm tra interface động khả dĩ, rồi tìm những kiểu thoả mãn chúng nhưng thiếu method đã biên dịch, rồi gọi lại compiler để thêm những method đó.
Cách này sẽ làm việc build chậm đi đáng kể, vì phải dừng lại sau khi liên kết và lặp lại một số lần biên dịch. Nó đặc biệt làm chậm các lần build tăng dần. Tệ hơn, có thể xảy ra chuyện mã method mới biên dịch lại tự nó có thêm các phép kiểm tra interface động mới, và quá trình sẽ phải lặp lại. Có thể dựng được những ví dụ mà quá trình này thậm chí không bao giờ kết thúc.
-
Hiện thực một dạng JIT nào đó, biên dịch mã method cần thiết lúc chạy.
Go hưởng lợi rất nhiều từ sự đơn giản và hiệu năng dự đoán được của việc biên dịch hoàn toàn trước lúc chạy. Chúng tôi ngại gánh lấy sự phức tạp của một JIT chỉ để hiện thực một tính năng ngôn ngữ.
-
Thu xếp phát sinh một đường dự phòng chậm cho mỗi generic method, dùng một bảng hàm cho mọi thao tác ngôn ngữ khả dĩ trên type parameter, rồi dùng hiện thực dự phòng đó cho các phép kiểm tra động.
Cách tiếp cận này sẽ làm một generic method được tham số hoá bởi một kiểu bất ngờ chậm hơn nhiều so với chính method đó được tham số hoá bởi một kiểu đã quan sát được lúc biên dịch. Điều này khiến hiệu năng khó dự đoán hơn nhiều.
-
Định nghĩa rằng generic method hoàn toàn không thể dùng để thoả mãn interface.
Interface là một phần thiết yếu của lập trình trong Go. Việc cấm generic method thoả mãn interface là không chấp nhận được xét từ góc độ thiết kế.
Không lựa chọn nào trong số này là tốt, nên chúng tôi chọn “không cái nào ở trên.”
Thay vì method có type parameter, hãy dùng hàm cấp cao nhất có type parameter, hoặc thêm type parameter vào kiểu của receiver.
Để biết thêm chi tiết, kể cả nhiều ví dụ hơn, xem đề xuất.
Vì sao tôi không thể dùng một kiểu cụ thể hơn cho receiver của một kiểu được tham số hoá?
Các khai báo method của một kiểu generic được viết với một receiver bao gồm tên của các type parameter. Có lẽ vì cú pháp chỉ định kiểu tại điểm gọi trông tương tự, một số người đã nghĩ rằng điều này cung cấp một cơ chế để tạo ra một method được tuỳ biến cho các đối số kiểu nhất định bằng cách nêu một kiểu cụ thể trong receiver, chẳng hạn string:
type S[T any] struct { f T }
func (s S[string]) Add(t string) string {
return s.f + t
}
Cách này thất bại vì từ string được compiler hiểu là tên của đối số kiểu trong method đó. Thông báo lỗi của compiler sẽ đại loại như “operator + not defined on s.f (variable of type string)”. Điều này có thể gây bối rối vì toán tử + hoạt động tốt trên kiểu khai báo sẵn string, nhưng khai báo đã ghi đè, đối với method này, định nghĩa của string, và toán tử không hoạt động trên phiên bản string không liên quan đó. Việc ghi đè một tên khai báo sẵn như vậy là hợp lệ, nhưng là điều kỳ quặc và thường là một sai lầm.
Vì sao compiler không suy diễn được đối số kiểu trong chương trình của tôi?
Có nhiều trường hợp lập trình viên dễ dàng thấy đối số kiểu cho một kiểu hay hàm generic phải là gì, nhưng ngôn ngữ không cho phép compiler suy diễn ra nó. Type inference bị giới hạn một cách có chủ ý để bảo đảm không bao giờ có sự nhầm lẫn về việc kiểu nào được suy diễn. Kinh nghiệm với các ngôn ngữ khác gợi ý rằng type inference ngoài mong đợi có thể dẫn tới nhầm lẫn đáng kể khi đọc và gỡ lỗi một chương trình. Luôn có thể chỉ định tường minh đối số kiểu cần dùng trong lời gọi. Trong tương lai, các dạng suy diễn mới có thể được hỗ trợ, miễn là các quy tắc vẫn đơn giản và rõ ràng.
Package và kiểm thử
Làm sao tôi tạo một package nhiều tệp?
Đặt mọi tệp mã nguồn của package vào riêng một thư mục. Các tệp mã nguồn có thể tham chiếu tới các thành phần từ những tệp khác tuỳ ý; không cần khai báo trước hay tệp header.
Ngoài việc bị chia thành nhiều tệp, package sẽ biên dịch và kiểm thử y như một package một tệp.
Làm sao tôi viết một unit test?
Tạo một tệp mới kết thúc bằng _test.go trong cùng thư mục với mã nguồn package của bạn. Bên trong tệp đó, import "testing" và viết các hàm có dạng
func TestFoo(t *testing.T) {
...
}
Chạy go test trong thư mục đó. Script đó tìm các hàm Test, build một binary kiểm thử và chạy nó.
Xem tài liệu How to Write Go Code, package testing và lệnh con go test để biết thêm chi tiết.
Hàm trợ giúp yêu thích của tôi để kiểm thử đâu rồi?
Package testing chuẩn của Go giúp viết unit test dễ dàng, nhưng nó thiếu những tính năng có trong các framework kiểm thử của ngôn ngữ khác, chẳng hạn các hàm assertion. Một mục trước đó của tài liệu này đã giải thích vì sao Go không có assertion, và các lập luận tương tự áp dụng cho việc dùng assert trong kiểm thử. Xử lý lỗi đúng cách nghĩa là để các test khác tiếp tục chạy sau khi một test thất bại, để người gỡ lỗi thất bại đó có được bức tranh đầy đủ về những gì đang sai. Sẽ hữu ích hơn khi một test báo rằng isPrime cho câu trả lời sai với 2, 3, 5 và 7 (hoặc với 2, 4, 8 và 16) so với việc báo rằng isPrime cho câu trả lời sai với 2 và do đó không có test nào khác được chạy. Lập trình viên kích hoạt thất bại của test có thể không quen với đoạn mã bị lỗi. Thời gian đầu tư viết một thông báo lỗi tốt bây giờ sẽ được đền đáp sau này khi test hỏng.
Một điểm liên quan là các framework kiểm thử có xu hướng phát triển thành những tiểu ngôn ngữ riêng của chúng, với điều kiện, cấu trúc điều khiển và cơ chế in ấn, nhưng Go đã có sẵn mọi khả năng đó; tại sao phải tạo lại chúng? Chúng tôi thà viết test bằng Go; như vậy bớt phải học một ngôn ngữ và cách tiếp cận này giữ cho test thẳng thắn và dễ hiểu.
Nếu lượng mã phụ cần viết để tạo ra thông báo lỗi tốt có vẻ lặp đi lặp lại và quá tải, test có thể sẽ tốt hơn nếu được lái bằng bảng, lặp qua một danh sách các đầu vào và đầu ra được định nghĩa trong một cấu trúc dữ liệu (Go hỗ trợ rất tốt các literal cấu trúc dữ liệu). Công sức viết một test tốt và các thông báo lỗi tốt khi đó sẽ được phân bổ đều qua nhiều trường hợp kiểm thử. Thư viện chuẩn của Go đầy những ví dụ minh hoạ, chẳng hạn trong các test định dạng cho package fmt.
Vì sao X không có trong thư viện chuẩn?
Mục đích của thư viện chuẩn là hỗ trợ thư viện runtime, kết nối với hệ điều hành, và cung cấp các chức năng cốt lõi mà nhiều chương trình Go cần đến, chẳng hạn I/O có định dạng và mạng. Nó cũng chứa những thành phần quan trọng cho lập trình web, bao gồm mật mã học và hỗ trợ các chuẩn như HTTP, JSON và XML.
Không có tiêu chí rõ ràng nào định nghĩa cái gì được đưa vào, bởi trong một thời gian dài, đây là thư viện Go duy nhất. Tuy nhiên hiện có những tiêu chí xác định cái gì được thêm vào ngày nay.
Những bổ sung mới cho thư viện chuẩn là hiếm và ngưỡng để được đưa vào rất cao. Mã nằm trong thư viện chuẩn gánh chi phí bảo trì liên tục lớn (thường do người khác chứ không phải tác giả gốc gánh), chịu ràng buộc của cam kết tương thích Go 1 (chặn việc sửa mọi khiếm khuyết trong API), và chịu ràng buộc của lịch phát hành Go, khiến các bản sửa lỗi không đến tay người dùng nhanh chóng.
Phần lớn mã mới nên nằm ngoài thư viện chuẩn và có thể truy cập qua lệnh go get của công cụ go. Mã như vậy có thể có người bảo trì riêng, chu kỳ phát hành riêng, và bảo đảm tương thích riêng. Người dùng có thể tìm package và đọc tài liệu của chúng tại pkg.go.dev.
Mặc dù có những mảnh trong thư viện chuẩn thực sự không thuộc về đó, chẳng hạn log/syslog, chúng tôi vẫn tiếp tục bảo trì mọi thứ trong thư viện vì cam kết tương thích Go 1. Nhưng chúng tôi khuyến khích phần lớn mã mới nên nằm ở nơi khác.
Hiện thực
Công nghệ compiler nào được dùng để xây dựng các compiler?
Có vài compiler dùng cho sản xuất dành cho Go, và một số compiler khác đang được phát triển cho nhiều nền tảng.
Compiler mặc định, gc, đi kèm bản phân phối Go như một phần hỗ trợ cho lệnh go. Gc ban đầu được viết bằng C vì những khó khăn trong việc bootstrap—bạn cần một compiler Go để dựng nên môi trường Go. Nhưng mọi thứ đã tiến triển và kể từ bản phát hành Go 1.5, compiler đã là một chương trình Go. Compiler được chuyển từ C sang Go bằng các công cụ dịch tự động, như mô tả trong tài liệu thiết kế này và bài nói này. Vì vậy compiler giờ đây “tự thân” (self-hosting), nghĩa là chúng tôi cần đối mặt với vấn đề bootstrap. Giải pháp là phải có sẵn một bản cài đặt Go hoạt động được, giống như bình thường ta có một bản cài đặt C hoạt động được. Câu chuyện về cách dựng một môi trường Go mới từ mã nguồn được mô tả ở đây và ở đây.
Gc được viết bằng Go với bộ phân tích cú pháp đệ quy đi xuống và dùng một bộ nạp tuỳ biến, cũng viết bằng Go nhưng dựa trên bộ nạp của Plan 9, để sinh ra các binary ELF/Mach-O/PE.
Compiler Gccgo là một front end viết bằng C++ với bộ phân tích cú pháp đệ quy đi xuống, gắn với back end GCC tiêu chuẩn. Một back end LLVM thử nghiệm đang dùng chung front end đó.
Vào lúc khởi đầu dự án, chúng tôi đã cân nhắc dùng LLVM cho gc nhưng quyết định rằng nó quá lớn và chậm để đáp ứng các mục tiêu hiệu năng của chúng tôi. Nhìn lại thì quan trọng hơn, việc bắt đầu với LLVM sẽ khiến việc đưa vào một số thay đổi về ABI và các thay đổi liên quan, như quản lý stack, mà Go đòi hỏi nhưng không nằm trong thiết lập C tiêu chuẩn, trở nên khó khăn hơn.
Hoá ra Go là một ngôn ngữ tốt để hiện thực một compiler Go, dù đó không phải mục tiêu ban đầu của nó. Việc không tự thân ngay từ đầu đã cho phép thiết kế của Go tập trung vào trường hợp sử dụng ban đầu của nó, đó là máy chủ mạng. Nếu chúng tôi đã quyết định sớm rằng Go phải tự biên dịch chính nó, có lẽ chúng tôi đã kết thúc với một ngôn ngữ nhắm nhiều hơn tới việc xây dựng compiler, đó là một mục tiêu đáng giá nhưng không phải mục tiêu chúng tôi có ban đầu.
Mặc dù gc có hiện thực riêng của nó, một bộ phân tích từ vựng và bộ phân tích cú pháp thuần Go có sẵn trong package go/parser và cũng có một bộ kiểm tra kiểu thuần Go. Compiler gc dùng các biến thể của những thư viện này.
Phần hỗ trợ lúc chạy được hiện thực thế nào?
Cũng vì các vấn đề bootstrap, mã lúc chạy ban đầu chủ yếu được viết bằng C (với một chút hợp ngữ) nhưng từ đó đã được dịch sang Go (trừ một vài mẩu hợp ngữ). Phần hỗ trợ lúc chạy của Gccgo dùng glibc. Compiler gccgo hiện thực goroutine bằng một kỹ thuật gọi là stack phân đoạn, được hỗ trợ bởi các sửa đổi gần đây cho linker gold. Gollvm tương tự được xây trên hạ tầng LLVM tương ứng.
Vì sao chương trình tầm thường của tôi lại cho ra binary lớn đến vậy?
Linker trong toolchain gc mặc định tạo ra các binary liên kết tĩnh. Vì vậy mọi binary Go đều bao gồm runtime Go, cùng với thông tin kiểu lúc chạy cần thiết để hỗ trợ kiểm tra kiểu động, phản chiếu, và cả stack trace lúc panic.
Một chương trình C “hello, world” đơn giản được biên dịch và liên kết tĩnh bằng gcc trên Linux có kích thước khoảng 750 kB, bao gồm cả một hiện thực của printf. Một chương trình Go tương đương dùng fmt.Printf nặng vài megabyte, nhưng nó bao gồm phần hỗ trợ lúc chạy mạnh hơn cùng thông tin kiểu và gỡ lỗi.
Một chương trình Go biên dịch bằng gc có thể được liên kết với cờ -ldflags=-w để tắt việc sinh DWARF, loại bỏ thông tin gỡ lỗi khỏi binary mà không mất chức năng nào khác. Cách này có thể giảm kích thước binary đáng kể.
Tôi có thể ngăn những lời phàn nàn về biến/import không dùng đến không?
Sự hiện diện của một biến không dùng đến có thể là dấu hiệu của lỗi, trong khi các import không dùng đến chỉ làm chậm việc biên dịch, một tác động có thể trở nên đáng kể khi một chương trình tích tụ mã và lập trình viên theo thời gian. Vì những lý do đó, Go từ chối biên dịch các chương trình có biến hoặc import không dùng đến, đánh đổi sự tiện lợi ngắn hạn lấy tốc độ build và sự rõ ràng của chương trình về lâu dài.
Dù vậy, khi đang phát triển mã, việc tạo ra những tình huống này tạm thời là chuyện thường, và sẽ phiền toái nếu phải xoá chúng đi trước khi chương trình biên dịch được.
Một số người đã đề nghị có một tuỳ chọn compiler để tắt những kiểm tra đó hoặc ít nhất giảm chúng xuống mức cảnh báo. Tuy nhiên, một tuỳ chọn như vậy đã không được thêm vào, vì các tuỳ chọn compiler không nên ảnh hưởng tới ngữ nghĩa của ngôn ngữ, và vì compiler Go không báo cảnh báo, chỉ báo lỗi ngăn việc biên dịch.
Có hai lý do cho việc không có cảnh báo. Thứ nhất, nếu điều đó đáng để phàn nàn thì nó đáng được sửa trong mã. (Ngược lại, nếu nó không đáng sửa thì không đáng nhắc đến.) Thứ hai, việc để compiler sinh cảnh báo khuyến khích hiện thực cảnh báo về những trường hợp yếu ớt, làm việc biên dịch trở nên ồn ào, che lấp những lỗi thực sự cần được sửa.
Tuy nhiên, giải quyết tình huống này rất dễ. Dùng định danh trống để giữ những thứ chưa dùng đến trong lúc bạn đang phát triển.
import "unused"
// This declaration marks the import as used by referencing an
// item from the package.
var _ = unused.Item // TODO: Delete before committing!
func main() {
debugData := debug.Profile()
_ = debugData // Used only during debugging.
....
}
Ngày nay, phần lớn lập trình viên Go dùng một công cụ, goimports, tự động viết lại tệp mã nguồn Go để có các import đúng, loại bỏ vấn đề import không dùng đến trên thực tế. Chương trình này dễ dàng kết nối với hầu hết các trình soạn thảo và IDE để chạy tự động khi một tệp mã nguồn Go được ghi. Chức năng này cũng được tích hợp sẵn trong gopls, như đã bàn ở trên.
Vì sao phần mềm quét virus của tôi cho rằng bản phân phối Go hoặc binary đã biên dịch của tôi bị nhiễm?
Đây là chuyện thường gặp, đặc biệt trên máy Windows, và hầu như luôn là báo động giả. Các chương trình quét virus thương mại thường bị bối rối bởi cấu trúc của binary Go, thứ mà chúng không gặp thường xuyên như binary biên dịch từ các ngôn ngữ khác.
Nếu bạn vừa cài đặt bản phân phối Go và hệ thống báo rằng nó bị nhiễm, thì chắc chắn đó là nhầm lẫn. Để thật kỹ lưỡng, bạn có thể xác minh bản tải về bằng cách so sánh checksum với các checksum trên trang tải về.
Trong mọi trường hợp, nếu bạn tin rằng báo cáo đó là sai, hãy báo lỗi cho nhà cung cấp phần mềm quét virus của bạn. Có lẽ theo thời gian các phần mềm quét virus có thể học cách hiểu chương trình Go.
Hiệu năng
Vì sao Go chạy kém trên benchmark X?
Một trong các mục tiêu thiết kế của Go là tiệm cận hiệu năng của C cho các chương trình tương đương, thế nhưng trên một số benchmark nó lại khá tệ, bao gồm vài benchmark trong golang.org/x/exp/shootout. Những benchmark chậm nhất phụ thuộc vào các thư viện mà Go chưa có phiên bản với hiệu năng tương đương. Ví dụ, pidigits.go phụ thuộc vào một package toán học đa độ chính xác, và các phiên bản C, khác với Go, dùng GMP (được viết bằng hợp ngữ tối ưu). Các benchmark phụ thuộc vào biểu thức chính quy (regex-dna.go, chẳng hạn) về cơ bản là so sánh package regexp thuần Go với các thư viện biểu thức chính quy chín muồi, được tối ưu cao như PCRE.
Các cuộc đua benchmark được thắng bằng việc tinh chỉnh dày công và phiên bản Go của phần lớn các benchmark cần được chăm chút. Nếu bạn đo những chương trình C và Go thực sự tương đương (reverse-complement.go là một ví dụ), bạn sẽ thấy hai ngôn ngữ gần nhau về hiệu năng thô hơn nhiều so với những gì bộ benchmark này gợi ý.
Dù vậy, vẫn còn chỗ để cải thiện. Các compiler tốt nhưng có thể tốt hơn, nhiều thư viện cần được cải thiện hiệu năng đáng kể, và garbage collector vẫn chưa đủ nhanh. (Ngay cả khi nó đủ nhanh, việc cẩn thận không sinh ra rác không cần thiết cũng có thể có tác động rất lớn.)
Trong mọi trường hợp, Go thường có thể rất cạnh tranh. Đã có cải thiện đáng kể về hiệu năng của nhiều chương trình khi ngôn ngữ và công cụ phát triển. Xem bài blog về profiling chương trình Go để có một ví dụ nhiều thông tin. Nó khá cũ nhưng vẫn chứa thông tin hữu ích.
Những thay đổi so với C
Vì sao cú pháp khác C đến vậy?
Ngoài cú pháp khai báo ra, các khác biệt không lớn và xuất phát từ hai mong muốn. Thứ nhất, cú pháp nên cảm thấy nhẹ nhàng, không có quá nhiều từ khoá bắt buộc, sự lặp lại, hay điều bí hiểm. Thứ hai, ngôn ngữ được thiết kế để dễ phân tích và có thể phân tích cú pháp mà không cần bảng ký hiệu. Điều này khiến việc xây dựng các công cụ như trình gỡ lỗi, bộ phân tích phụ thuộc, bộ trích xuất tài liệu tự động, plug-in IDE, v.v. dễ hơn nhiều. C và các hậu duệ của nó nổi tiếng là khó khăn về khoản này.
Vì sao các khai báo lại ngược?
Chúng chỉ ngược nếu bạn quen với C. Trong C, ý niệm là một biến được khai báo giống như một biểu thức biểu thị kiểu của nó, đó là một ý tưởng hay, nhưng ngữ pháp của kiểu và của biểu thức không hoà hợp lắm và kết quả có thể gây bối rối; hãy nghĩ tới con trỏ hàm. Go phần lớn tách cú pháp biểu thức khỏi cú pháp kiểu và điều đó làm mọi thứ đơn giản hơn (việc dùng * tiền tố cho con trỏ là ngoại lệ chứng minh cho quy tắc). Trong C, khai báo
int* a, b;
khai báo a là con trỏ nhưng b thì không; trong Go
var a, b *int
khai báo cả hai đều là con trỏ. Điều này rõ ràng và đều đặn hơn. Ngoài ra, dạng khai báo ngắn := cho thấy một khai báo biến đầy đủ nên trình bày cùng thứ tự như :=, nên
var a uint64 = 1
có cùng tác dụng như
a := uint64(1)
Việc phân tích cú pháp cũng được đơn giản hoá nhờ có một ngữ pháp riêng cho kiểu, chứ không phải chỉ là ngữ pháp biểu thức; các từ khoá như func và chan giữ mọi thứ rõ ràng.
Xem bài viết về Cú pháp khai báo của Go để biết thêm chi tiết.
Vì sao không có số học con trỏ?
An toàn. Không có số học con trỏ thì có thể tạo ra một ngôn ngữ không bao giờ suy ra được một địa chỉ bất hợp pháp mà lại thành công một cách sai lầm. Công nghệ compiler và phần cứng đã tiến tới mức mà một vòng lặp dùng chỉ số mảng có thể hiệu quả ngang một vòng lặp dùng số học con trỏ. Ngoài ra, việc thiếu số học con trỏ có thể đơn giản hoá hiện thực của garbage collector.
Vì sao ++ và -- là câu lệnh chứ không phải biểu thức? Và vì sao hậu tố, không phải tiền tố?
Không có số học con trỏ thì giá trị tiện lợi của các toán tử tăng tiền tố và hậu tố giảm xuống. Bằng cách loại chúng khỏi hệ thống phân cấp biểu thức hoàn toàn, cú pháp biểu thức được đơn giản hoá và những vấn đề rối rắm quanh thứ tự đánh giá của ++ và -- (hãy nghĩ tới f(i++) và p[i] = q[++i]) cũng bị loại bỏ. Sự đơn giản hoá này rất đáng kể. Còn về hậu tố so với tiền tố, cả hai đều ổn nhưng phiên bản hậu tố truyền thống hơn; việc khăng khăng dùng tiền tố nảy sinh cùng STL, một thư viện cho một ngôn ngữ mà tên của nó, trớ trêu thay, chứa một phép tăng hậu tố.
Vì sao có dấu ngoặc nhọn nhưng không có dấu chấm phẩy? Và vì sao tôi không thể đặt dấu ngoặc mở ở dòng tiếp theo?
Go dùng dấu ngoặc nhọn để nhóm câu lệnh, một cú pháp quen thuộc với những lập trình viên từng làm việc với bất kỳ ngôn ngữ nào thuộc họ C. Tuy nhiên, dấu chấm phẩy là dành cho bộ phân tích cú pháp, không phải cho con người, và chúng tôi muốn loại bỏ chúng càng nhiều càng tốt. Để đạt mục tiêu này, Go vay mượn một mẹo từ BCPL: các dấu chấm phẩy ngăn cách câu lệnh có trong ngữ pháp hình thức nhưng được chèn tự động, không cần nhìn trước, bởi bộ phân tích từ vựng ở cuối bất kỳ dòng nào có thể là kết thúc của một câu lệnh. Cách này hoạt động rất tốt trên thực tế nhưng có tác dụng là nó buộc phải theo một phong cách đặt ngoặc. Ví dụ, dấu ngoặc mở của một hàm không thể nằm một mình trên một dòng.
Một số người đã lập luận rằng bộ phân tích từ vựng nên nhìn trước để cho phép dấu ngoặc nằm ở dòng tiếp theo. Chúng tôi không đồng ý. Vì mã Go vốn được định dạng tự động bởi gofmt, một phong cách nào đó phải được chọn. Phong cách đó có thể khác với những gì bạn từng dùng trong C hay Java, nhưng Go là một ngôn ngữ khác và phong cách của gofmt cũng tốt như bất kỳ phong cách nào khác. Quan trọng hơn—quan trọng hơn nhiều—những lợi ích của một định dạng duy nhất, được áp đặt bằng chương trình cho mọi chương trình Go, lớn hơn hẳn bất kỳ bất lợi được cảm nhận nào của phong cách cụ thể đó. Cũng lưu ý rằng phong cách của Go nghĩa là một hiện thực tương tác của Go có thể dùng cú pháp tiêu chuẩn từng dòng một mà không cần quy tắc đặc biệt.
Vì sao lại dùng garbage collection? Nó có quá đắt đỏ không?
Một trong những nguồn ghi sổ lớn nhất trong các chương trình hệ thống là quản lý vòng đời của các đối tượng được cấp phát. Trong những ngôn ngữ như C nơi việc này làm thủ công, nó có thể ngốn một lượng đáng kể thời gian của lập trình viên và thường là nguyên nhân của những lỗi hiểm ác. Ngay cả trong những ngôn ngữ như C++ hay Rust vốn cung cấp cơ chế hỗ trợ, các cơ chế đó có thể ảnh hưởng đáng kể tới thiết kế của phần mềm, thường thêm vào chi phí lập trình của riêng chúng. Chúng tôi cảm thấy việc loại bỏ những chi phí đó cho lập trình viên là tối quan trọng, và những tiến bộ trong công nghệ garbage collection những năm gần đây cho chúng tôi niềm tin rằng nó có thể được hiện thực đủ rẻ, với độ trễ đủ thấp, để trở thành một cách tiếp cận khả thi cho các hệ thống mạng.
Phần lớn khó khăn của lập trình đồng thời bắt nguồn từ vấn đề vòng đời đối tượng: khi các đối tượng được truyền giữa các luồng, việc bảo đảm chúng được giải phóng an toàn trở nên lôi thôi. Garbage collection tự động khiến mã đồng thời dễ viết hơn nhiều. Dĩ nhiên, hiện thực garbage collection trong môi trường đồng thời tự nó là một thách thức, nhưng đáp ứng thách thức đó một lần thay vì trong mọi chương trình thì giúp ích cho tất cả mọi người.
Cuối cùng, gác chuyện đồng thời sang một bên, garbage collection làm cho interface đơn giản hơn vì chúng không cần chỉ rõ bộ nhớ được quản lý ra sao qua chúng.
Điều này không có nghĩa là những công việc gần đây trong các ngôn ngữ như Rust, vốn mang ý tưởng mới tới vấn đề quản lý tài nguyên, là lạc lối; chúng tôi khuyến khích công việc này và háo hức chờ xem nó tiến hoá ra sao. Nhưng Go theo cách tiếp cận truyền thống hơn bằng cách giải quyết vòng đời đối tượng thông qua garbage collection, và chỉ garbage collection mà thôi.
Hiện thực hiện tại là một bộ thu gom mark-and-sweep. Nếu máy là đa xử lý, bộ thu gom chạy trên một lõi CPU riêng song song với chương trình chính. Công việc lớn trên bộ thu gom những năm gần đây đã giảm thời gian tạm dừng thường xuống dưới mức một mili giây, ngay cả với heap lớn, gần như loại bỏ một trong những phản đối chính đối với garbage collection trong các máy chủ mạng. Công việc vẫn tiếp tục để tinh chỉnh thuật toán, giảm chi phí và độ trễ hơn nữa, và khám phá các cách tiếp cận mới. Bài phát biểu chính tại ISMM năm 2018 của Rick Hudson thuộc nhóm Go mô tả tiến bộ đạt được cho tới nay và gợi ý một số cách tiếp cận tương lai.
Về chủ đề hiệu năng, hãy nhớ rằng Go cho lập trình viên quyền kiểm soát đáng kể đối với bố cục bộ nhớ và cấp phát, nhiều hơn hẳn mức thường thấy ở các ngôn ngữ có garbage collection. Một lập trình viên cẩn thận có thể giảm chi phí garbage collection đáng kể bằng cách dùng ngôn ngữ cho khéo; xem bài viết về profiling chương trình Go để có một ví dụ cụ thể, bao gồm cả minh hoạ các công cụ profiling của Go.