3 điểm bởi GN⁺ 2023-11-24 | 1 bình luận | Chia sẻ qua WhatsApp
  • Trên các hệ Unix, có thể có tệp thực thi /bin/[ với tên chỉ là một ký hiệu một ký tự; cú pháp trông giống biểu thức điều kiện của shell thực ra cũng được xây dựng trên việc thực thi lệnh và mã thoát
  • test đánh giá biểu thức và trả về 0 nếu đúng, 1 nếu sai; khi được gọi là [, nó còn kiểm tra xem đối số cuối cùng có phải là ] hay không
  • Nhiều shell cũng cung cấp test[ dưới dạng lệnh tích hợp, nên thông báo lỗi hoặc hành vi của /bin/test bên ngoài và phần triển khai tích hợp trong shell có thể khác nhau
  • [[, phần mở rộng của Bash, không phải lệnh bên ngoài mà là cú pháp tích hợp, nên áp dụng các quy tắc khác với [, chẳng hạn như trong ví dụ, nó không mở rộng glob long* mà so sánh như chuỗi literal
  • Với script cần tính di động, [ là lựa chọn phù hợp; nếu chỉ dành cho Bash thì nên dùng nhất quán [[, nhưng cần hiểu khác biệt về quy tắc mở rộng giữa hai cách để chọn cho đúng

Bản chất của /bin/[/bin/test

  • Trên hệ thống Unix có thể tồn tại tệp thực thi /bin/[ với tên chỉ là một ký hiệu một ký tự
    • Lệnh ví dụ ls /bin/? sẽ hiển thị /bin/[
  • /bin/[/bin/test có thể trỏ tới cùng một binary
    • Trong ví dụ, hai đường dẫn xuất hiện như các tệp có cùng inode và kích thước
    • Tuy nhiên không nhất thiết hệ thống nào cũng phải dùng hard link
  • test là chương trình dùng để đánh giá biểu thức trong shell
    • So sánh chuỗi
    • So sánh số
    • Kiểm tra điều kiện tệp
  • Nếu kết quả đánh giá là đúng, nó trả về mã thoát 0; nếu sai, trả về 1

Vì sao [ hoạt động như một lệnh

  • test a = b khó trông giống biểu thức điều kiện, nhưng nếu viết cùng logic dưới dạng [ a = b ] thì sẽ quen thuộc hơn
  • [ trông như cú pháp riêng, nhưng thực tế là một lời gọi lệnh
    • if [ a = b ]; then ... fi chạy lệnh [ rồi kiểm tra mã thoát
    • Khi được gọi là [, chương trình kiểm tra xem đối số cuối cùng có phải là dấu ngoặc vuông đóng ] hay không
  • Câu lệnh if không trực tiếp diễn giải biểu thức điều kiện, mà rẽ nhánh dựa trên mã thoát của lệnh được truyền vào
    • test a = a; echo $?0
    • test a = b; echo $?1
    • [ a = a ]; echo $?0
    • [ a = b ]; echo $?1
  • Cùng ngữ cảnh đó, truefalse cũng có thể được xem là các binary phụ trợ trả về mã thoát

Khác biệt giữa binary bên ngoài và lệnh tích hợp của shell

  • test[ được dùng thường xuyên trong shell script, hầu hết shell cũng triển khai chúng dưới dạng lệnh tích hợp
  • Với cùng một đầu vào, đầu ra của binary bên ngoài và lệnh tích hợp của shell có thể khác nhau
    • /bin/test a btest: a: unexpected operator
    • test a bdash: 2: test: a: unexpected operator
  • Những khác biệt như vậy có thể xảy ra không chỉ với test[, mà cả với những lệnh trông đơn giản như echo
  • Mỗi shell có phần triển khai tích hợp khác nhau, nên hành vi của script có thể thay đổi tùy theo shell thực thi

Các quy tắc riêng do phần mở rộng Bash [[ áp dụng

  • [[ là phần mở rộng của Bash và có thể dùng thay cho [
  • Khác biệt lớn nhất là [[ luôn là cú pháp tích hợp
    • Khác với [ có thể được chạy dưới dạng binary bên ngoài, với [[, Bash có thể thay đổi các quy tắc ngôn ngữ bên trong biểu thức
  • Trong ví dụ về glob, [[[ hoạt động khác nhau
    • Sau touch long-name, [ long* = long-name ] && echo match sẽ in ra match
    • Các quy tắc mở rộng shell thông thường được áp dụng cho đối số của lệnh [, nên long* được mở rộng thành long-name trong thư mục
    • [[ long* = long-name ]] && echo match không in gì
    • [[ xử lý long* như chuỗi literal và so sánh nguyên trạng với long-name, nên thất bại
  • Nếu là script chỉ dành cho Bash, bạn cũng có thể dùng các tính năng như khớp biểu thức chính quy =~ với [[

Nên chọn gì trong script

  • Với shell script cần tính di động, nên dùng [
  • Cũng có thể dùng test, nhưng đây không phải lựa chọn phổ biến
  • Nếu script chỉ dành cho Bash, nên dùng nhất quán [[
  • Bản thân shell cũng có các toán tử biểu thức như !, &&, ||
    • Các toán tử này hoạt động dựa trên trạng thái thoát của lệnh
    • grep ^hello$ ... && grep ^bye$ ... sẽ có mã thoát tổng thể là 0 nếu cả hai lệnh đều thành công
    • Nếu lệnh grep đầu tiên thất bại, lệnh sau && sẽ không thành công theo, nên mã thoát tổng thể là 1
  • Vì vậy có thể kết hợp biểu thức test/[ và toán tử logic của shell trong cùng một câu điều kiện
    • Ví dụ: [ a = b ] || grep -q ^hello$ /usr/share/dict/words
  • POSIX không yêu cầu /bin/[/bin/test phải là hard link
    • Trên NetBSD, chúng là hard link
    • macOS Catalina cung cấp các bản sao riêng của cùng một binary
    • Debian testing cung cấp hai binary khác nhau
    • Đặc tả POSIX không yêu cầu hai tệp này phải là liên kết với nhau

1 bình luận

 
GN⁺ 2023-11-24
Các ý kiến trên Hacker News
  • Tôi là tác giả bài gốc. Cảm ơn đã chia sẻ, và rất vui vì bài đã lên tới trang nhất. Có lẽ tiêu đề nên có thêm (2020), và vì "test" thực ra là chỉ một lệnh nên tốt hơn là không viết hoa
    Tôi cũng có một bài liên quan viết năm 2021, trong đó đề cập cả toán tử [[ của bash, nên có thể sẽ thú vị trong ngữ cảnh này: https://jmmv.dev/2021/08/useless-use-of-gnu.html

    • Nói nghiêm ngặt thì [[ không phải là lệnh dựng sẵn, mà về cơ bản gần với thành phần cú pháp hơn. Có lẽ bên trong nó dùng một lệnh dựng sẵn gần như không thể truy cập trực tiếp, nhưng điểm thú vị là ]] cũng là từ dành riêng dù nó không thể xuất hiện ở vị trí mà từ dành riêng có ý nghĩa
      Trong một số shell không phải bash, để khai báo một số loại hàm nhất định cần có từ khóa function. $(shell) của make có thể tạo ra khác biệt hiệu năng đo được khi build nhiều target. Dù vậy, nếu không làm gì cả thì vẫn là thiệt, nên thông thường nếu muốn kích hoạt việc tái sinh thì nên dùng include. Việc GNU phớt lờ POSIX là hoàn toàn hợp lý, vì POSIX không hữu ích lắm để giải quyết phần lớn vấn đề thực tế
    • Nhiều phần mở rộng GNU được đề cập trong https://jmmv.dev/2021/08/useless-use-of-gnu.html rất hữu ích khi dùng tương tác. Việc tìm kiếm trong thư mục hiện tại mà không cần chỉ rõ . cũng hữu ích, và việc thêm tùy chọn vào cuối lệnh vừa gõ thì thật sự tiện. Thấy lệnh nào không hỗ trợ những thứ đó là tôi luôn bực mình
      Trong script, nhìn chung nhắm tới POSIX sh là hợp lý. Ít nhất bạn phải biết mình có đang dùng cú pháp riêng của Bash hay không
    • Bài đó phàn nàn rằng dùng các phần mở rộng GNU như --ignore-case, set -o pipefail sẽ làm giảm tính di động của script. Bản thân điều đó là đúng
      Nhưng bài không giải thích vì sao người dùng Linux phải quá quan tâm đến tính di động. OpenBSD và FreeBSD vẫn sống tốt, nhưng số người dùng quá ít nên có vẻ không phải đối tượng cần đặc biệt lo lắng. Vì công bằng, có thể nói rằng cũng nên cân nhắc các hệ điều hành đó, nhưng tiêu chuẩn ấy nên dừng ở đâu? Có phải cân nhắc cả những thứ obscure như vxWorks không? Phía BusyBox, Alpine thì thú vị hơn, nhưng các thay đổi quá lớn nên dù sao gần như lúc nào cũng cần port riêng. Có lý do thuyết phục nào khác để phải quan tâm đến các hệ sinh thái không phải GNU không?
    • Những thứ như if [ a = b ] || grep -q ^hello$ /usr/share/dict/words; then echo "test failed and grep succeeded"; fi chẳng phải chỉ là cách dùng shell bình thường hằng ngày sao?
    • Nói thêm bên lề, có vẻ phần mềm blog đã làm hỏng tiêu đề. Ví dụ nó hiển thị thành make $(shell …) expansion, trong khi đáng ra phải là make $(shell ...) expansion
      Như trong phần nội dung viết đúng, đó là ba dấu chấm chứ không phải một ký tự dấu ba chấm, nên bản thân mldr cũng không đúng. Có lẽ hai lỗi không liên quan đã cùng lúc gây ảnh hưởng
  • Nếu đẩy ý cuối đi thêm một bước nữa thì có thể loại bỏ luôn cả khối if. if [ a = b ]; then echo "Oops!"; else echo "Expected; phew!"; fi trở thành [ a = b ] && echo "Oops!" || echo "Expected; phew!"
    Tôi không biết nên làm vậy thường xuyên đến mức nào, nhưng đôi khi hữu ích khi in thông tin debug có điều kiện ra lỗi chuẩn, như [ "$debug" ] && echo "what's going on" >&2. Việc khối if kiểm tra các lệnh thông thường cũng cho phép viết những thứ như if grep -q 'debug' /var/log/nginx/access.log; then echo "Debug request found!"; fi. Điều tôi chưa tìm hiểu là nên viết [ $(expr 1 + 1) -eq 2 ] && [ $(expr 2 + 2) -eq 3 ], hay dùng phép AND logic dựng sẵn của test[ $(expr 1 + 1) -eq 2 -a $(expr 2 + 2) -eq 4 ]. Nếu hiệu năng không phải vấn đề thì cả hai phía đều có vẻ hợp lý vì những lý do tương tự

    • Không nên xem [ a = b ] && echo "Oops!" || echo "Expected; phew!" như một quy tắc chung. Có lẽ bash sẽ diễn giải dòng này như ([ a = b ] && echo "Oops!") || echo "Expected; phew!"
      Vì vậy nếu chuỗi lệnh sau && thất bại thì đoạn mã sau || vẫn sẽ chạy. Ví dụ nếu >/dev/full echo "strings match" thất bại do lỗi ghi, thì dù chuỗi bằng nhau, "strings don't match" vẫn được in ra. Điều này khác với ý nghĩa của khối if
    • Tốt hơn là nên tránh kiểu rút gọn như vậy. Nếu bạn đang dùng set -e — thứ nên dùng — thì if [ a = b ]; then echo "Oops!"; fi hoạt động như mong đợi, nhưng [ a = b ] && echo "Oops!" sẽ thoát với lỗi khi biểu thức a không bằng b
    • Theo POSIX, các biểu thức cơ sở nhị phân -a, -o và các toán tử (, ) được đánh dấu là sẽ bị loại bỏ. Xem mục "Application Usage" tại https://pubs.opengroup.org/onlinepubs/9699919799/utilities/t... để biết chi tiết
    • Dù dùng -a hay hai phép kiểm tra với &&, nếu có thể dùng đánh giá số học của bash thì không cần chạy expr: [ $((1+1)) -eq 2 ]
  • Từ vài năm trước tôi đã bắt đầu không dùng [. test củng cố ý rằng đây không phải cú pháp, mà chỉ là một lệnh như các lệnh khác. Và tra man test dễ chịu hơn nhiều so với lục man bash

    • Nói vậy không khớp lắm. GNU Coreutils không chỉ có trang hướng dẫn man test, mà còn có cả man [
      Bash có help test như một cheat sheet nhanh. Lệnh [ đã rất lâu đời, và đã có trong Version 7 Unix từ năm 1979
  • Đồng ý. [[[ vốn chỉ dành cho bash thực sự gây ra nhiều nhầm lẫn về chuyện điều gì đang diễn ra, nên khó mà tự tin được
    Tuy nhiên, việc [[ được bảo đảm là lệnh tích hợp chắc chắn từng có mục đích vào thời hiệu năng script shell còn có ý nghĩa, mà cũng chưa phải quá xa xưa

    • Đọc xong bài này thì có lẽ tôi sẽ chỉ dùng test. Tôi không viết script bash thường xuyên nên lúc nào cũng vấp ở câu lệnh if, đặc biệt là quy tắc khoảng trắng. Sau khi thấy lý do thì mọi thứ trở nên quá hiển nhiên, và dùng test cũng thể hiện rõ hơn rằng ta chỉ đang truyền đối số mà thôi
  • Cái bẫy lớn nhất trong [testhành vi với một đối số. Ví dụ, để kiểm tra biến không rỗng, bạn có thể viết [ -n $FOO ]
    Nhưng nếu FOO chưa được đặt, nó sẽ được mở rộng thành không có gì, chứ không phải chuỗi rỗng, và trở thành tương đương [ -n ]. POSIX yêu cầu dạng một đối số của [ phải thành công nếu đối số đó, ở đây là "-n", không rỗng. Vì vậy nó báo sai rằng $FOO không rỗng. Biến nhất định phải được đặt trong dấu nháy

    • Câu cuối nên được đưa lên đầu. Hãy đặt biến trong dấu nháy. Bản thân đặc tả của lệnh tích hợp test không có bẫy; cái bẫy nằm ở chính shell
      Hành vi được nhắc tới là hợp lý. Vì [ "$FOO" ] là dạng luôn kiểm tra xem nội dung có không rỗng hay không, bất kể nội dung là gì, kể cả "-n"
    • Với script thì cứ chạy ShellCheck là được
    • Nếu $FOO chứa khoảng trắng, nó sẽ được mở rộng thành nhiều đối số. Cứ luôn đặt biến trong dấu nháy
    • [ x"$FOO" != x"" ]
    • Trong trường hợp này, tôi sẽ dùng [ -n "${FOO?}" ] để script dừng ngay nếu $FOO là null hoặc chưa được đặt
  • chubot đã viết một tài liệu thú vị đào sâu vào các phần tinh tế hơn của test/[/[[. Các bài khác trên blog đó cũng giải thích khá thú vị về những điểm kỳ lạ của shell
    ¹ https://www.oilshell.org/blog/2017/08/31.html
    ² https://www.oilshell.org/blog/2016/11/18.html

  • Tôi hoàn toàn không biết [ là một chương trình, và chuyện nó kiểm tra đối số cuối có phải dấu ngoặc vuông đóng hay không thì hơi buồn cười
    Dù vậy, điều đó giải thích vì sao cần khoảng trắng ở hai bên dấu ngoặc vuông

  • [[ chỉ dành cho bash. Nếu biết chắc mình sẽ chỉ dùng bash thì cứ dùng. Bài viết đã trình bày tốt phần chi tiết

    • zsh cũng có mà :)
      https://zsh.sourceforge.io/Doc/Release/Conditional-Expressio...
    • Cứ luôn dùng [[ là được
      zsh và ksh cũng có, và thật ra tôi gần như chắc nó bắt đầu từ ksh vào năm 1988 hoặc trước đó
    • Tức là test[ được POSIX quy định và thường tồn tại dưới dạng binary thực sự. Tuy nhiên, lệnh tích hợp của shell cũng có thể che chúng
      Trong khi đó [[ không được POSIX quy định và thường chỉ tồn tại dưới dạng lệnh tích hợp của shell
    • Nếu không phải đang chủ ý dùng một shell hoàn toàn khác như Fish, tôi không hiểu tại sao lại không dùng Bash
      Việc chỉ nhắm tới shell mẫu số chung nhỏ nhất nghe như một nỗi lo cổ lỗ
  • Tôi không hiểu lắm vì sao câu lệnh if cuối lại gây nhầm lẫn. Nếu là vì khi mới học shell script, người ta thường mặc định [ là một phần của ngôn ngữ script bash chứ không chỉ là một chương trình khác, thì giờ tôi hiểu. Nếu không phải vậy, mong có ai giải thích vì sao nó đáng ngạc nhiên

    • Dù không biết [ là binary, tôi cũng không hiểu vì sao lại khó hiểu. Nó trông như bash rất bình thường
  • Tôi có gu rất mạnh về shell, nhưng lại không hợp với đa số thế giới
    Tôi cho rằng tuyệt đối không nên dùng [, mà chỉ nên dùng test. [ khiến người ta lầm tưởng cơ chế đó là một phần của cú pháp ngôn ngữ, nhưng thực ra nó chỉ là một “chương trình” khác. Ở đây “chương trình” bao gồm cả lệnh tích hợp và hàm. if/||/&& nhìn vào trạng thái thoát, còn một chương trình thì sau khi mở rộng chỉ có thể nhìn trạng thái thoát của thứ khác thông qua biến ma thuật $?, vốn chỉ là chuỗi. case nhìn vào chuỗi, nhưng không hoạt động dựa trên trạng thái thoát và cũng không đặt trạng thái thoát như một phần của thao tác case ... esac. “Chương trình” mới đặt trạng thái thoát. Ngoài ra, [/test chỉ nên được dùng khi đánh giá cấu trúc hệ thống tập tin, chẳng hạn test -f /dev/null. Tôi cho rằng đánh giá chuỗi nên dùng case. Đương nhiên, khi nhìn hầu hết script tôi đều thấy ngứa ngáy, còn script tôi viết thì người khác thấy kỳ quặc

    • Trò đùa quay lại phía tôi rồi. Đó chính là ý chính của bài viết
      Khi dùng shell, tôi thích đặt chương trình sau if ở một dòng riêng rồi mới tới then. Là để nhấn mạnh rằng thứ sau if là “xem trạng thái thoát của lệnh cuối cùng trước then”. Ví dụ với cấu trúc như if; ls /tmp/goober; test -d /tmp/goober; echo "I'll always execute the then clause because echo will always return a 0 return code"; then ... fi, if rốt cuộc chỉ nhìn trạng thái thoát của echo, nên nhánh then luôn được chạy
    • Gặp được đồng chí cùng đi một con đường rồi. 8 năm script có test là bằng chứng rằng tôi đồng ý. Đó là con đường cô độc... Tôi đổ lỗi cho Google shell style guide
      Tôi hình thành thói quen thích dùng test khi viết script phải chạy được trên cả sh lẫn bash, nhưng vẫn tiếp tục dùng vì về mặt ngữ nghĩa nó hợp lý hơn việc coi ký tự [ như một lệnh. Việc ] không phải binary riêng mà là đối số của [ cũng kỳ lạ. Tôi hiểu lý do kỹ thuật, nhưng nó vẫn có cảm giác như một trò hack
    • Đọc bình luận ở đây thì có vẻ bằng cách nào đó cũng có hàng chục người chỉ dùng test. Hàng chục người!
  • Tôi được học là chỉ dùng [[ khi muốn khớp biểu thức chính quy. Ví dụ: if [[ "${foo}" =~ ^bar$ ]]; then echo Yes; fi
    Ngoài ra thì chỉ dùng "test" hoặc "[". Tôi đang ở dòng bash thứ 85.000. Không phải nói bash tuyệt vời, nhưng đến nay nó vẫn đáp ứng nhu cầu của tôi cho rất nhiều việc

    • Nếu muốn khớp mẫu tương đối đơn giản theo cách tương thích POSIX, expr có thể khớp biểu thức chính quy cơ bản và cũng có thể trả về nhóm bắt

1: https://pubs.opengroup.org/onlinepubs/9699919799/utilities/e...

  • Tôi đã bị mắc kẹt một thời gian vì thực tế là nó dùng nguyên biểu thức chính quy và không đặt trong dấu nháy. Vì tôi là kiểu người cứ nhất nhất cái gì cũng cho vào dấu nháy, nên mất một lúc mới hiểu vì sao một regex đơn giản đến ngớ ngẩn lại không khớp.
  • Ví dụ đó không có nhiều ý nghĩa. Chỉ cần [ "$foo" = bar ] && echo Yes là được
    Với so khớp chuỗi con, phần lớn trường hợp [ và glob * là đủ. Kiểu như [ "$bar" = extra* ] && echo '$bar began with extra'. Phương ngữ regex của Bash khá thô sơ nên hầu như không đáng để cố dùng. Với việc phức tạp thì nên dùng công cụ khác như grep, awk, perl. Nếu cứ ám ảnh muốn làm mọi thứ bằng bash, kể cả các tác vụ phức tạp cần khả năng tái sử dụng cao hơn, tính mô-đun tốt hơn và các kiểu dữ liệu tích hợp, thì lợi ích thu được sẽ giảm rất nhanh.