JavaRush /Blog Java /Random-VI /FindBugs giúp bạn học Java tốt hơn
articles
Mức độ

FindBugs giúp bạn học Java tốt hơn

Xuất bản trong nhóm
Máy phân tích mã tĩnh rất phổ biến vì chúng giúp tìm ra các lỗi xảy ra do sự bất cẩn. Nhưng điều thú vị hơn nhiều là họ giúp sửa chữa những sai lầm do thiếu hiểu biết. Ngay cả khi mọi thứ đều được viết trong tài liệu chính thức của ngôn ngữ đó thì thực tế không phải tất cả các lập trình viên đều đọc kỹ nó. Và các lập trình viên có thể hiểu: bạn sẽ cảm thấy mệt mỏi khi đọc tất cả tài liệu. Về mặt này, máy phân tích tĩnh giống như một người bạn có kinh nghiệm ngồi cạnh bạn và xem bạn viết mã. Anh ấy không chỉ nói với bạn: “Đây là chỗ bạn đã mắc lỗi khi sao chép và dán” mà còn nói: “Không, bạn không thể viết như vậy, hãy tự xem tài liệu”. Một người bạn như vậy hữu ích hơn chính tài liệu đó, bởi vì anh ta chỉ gợi ý những điều bạn thực sự gặp phải trong công việc của mình và im lặng về những điều sẽ không bao giờ hữu ích cho bạn. Trong bài đăng này, tôi sẽ nói về một số điều phức tạp trong Java mà tôi đã học được khi sử dụng bộ phân tích tĩnh FindBugs. Có lẽ một số điều cũng sẽ bất ngờ đối với bạn. Điều quan trọng là tất cả các ví dụ không mang tính suy đoán mà dựa trên mã thực.

Toán tử bậc ba ?:

Có vẻ như không có gì đơn giản hơn toán tử bậc ba, nhưng nó cũng có những cạm bẫy. Tôi tin rằng không có sự khác biệt cơ bản giữa các thiết kế Type var = condition ? valTrue : valFalse;Type var; if(condition) var = valTrue; else var = valFalse; hóa ra ở đây có sự tinh tế. Vì toán tử bậc ba có thể là một phần của biểu thức phức tạp nên kết quả của nó phải là loại cụ thể được xác định tại thời điểm biên dịch. Do đó, giả sử, với một điều kiện đúng ở dạng if, trình biên dịch dẫn valTrue trực tiếp đến kiểu Type, và ở dạng toán tử ba ngôi, trước tiên nó dẫn đến kiểu chung valTrue và valFalse (mặc dù thực tế là valFalse không phải là được đánh giá), và sau đó kết quả sẽ dẫn đến loại Type. Các quy tắc truyền không hoàn toàn tầm thường nếu biểu thức liên quan đến các kiểu nguyên thủy và các hàm bao trên chúng (Số nguyên, Kép, v.v.). Tất cả các quy tắc được mô tả chi tiết trong JLS 15.25. Hãy xem xét một số ví dụ. Number n = flag ? new Integer(1) : new Double(2.0); Điều gì sẽ xảy ra với n nếu cờ được đặt? Một đối tượng Double có giá trị là 1,0. Trình biên dịch nhận thấy những nỗ lực vụng về của chúng ta nhằm tạo ra một đối tượng buồn cười. Vì đối số thứ hai và thứ ba là các hàm bao trên các kiểu nguyên thủy khác nhau nên trình biên dịch sẽ mở chúng và tạo ra một kiểu chính xác hơn (trong trường hợp này là gấp đôi). Và sau khi thực hiện toán tử bậc ba cho nhiệm vụ, quyền anh lại được thực hiện. Về cơ bản, mã này tương đương với điều này: Number n; if( flag ) n = Double.valueOf((double) ( new Integer(1).intValue() )); else n = Double.valueOf(new Double(2.0).doubleValue()); Từ quan điểm của trình biên dịch, mã không có vấn đề gì và biên dịch hoàn hảo. Nhưng FindBugs đưa ra cảnh báo:
BX_UNBOXED_AND_COERCED_FOR_TERNARY_OPERATOR: Giá trị nguyên thủy không được đóng hộp và bị ép buộc đối với toán tử ba ngôi trong TestTernary.main(String[]) Một giá trị nguyên thủy được bao bọc sẽ được bỏ hộp và chuyển đổi sang một kiểu nguyên thủy khác như một phần của việc đánh giá toán tử ba ngôi có điều kiện (toán tử b? e1: e2 ). Ngữ nghĩa của Java bắt buộc rằng nếu e1 và e2 được bao bọc các giá trị số, thì các giá trị đó sẽ không được đóng hộp và chuyển đổi/ép buộc về kiểu chung của chúng (ví dụ: nếu e1 thuộc kiểu Integer và e2 thuộc kiểu Float, thì e1 không được đóng hộp, được chuyển đổi thành giá trị dấu phẩy động và được đóng hộp.Xem JLS Phần 15.25. Tất nhiên, FindBugs cũng cảnh báo rằng Integer.valueOf(1) hiệu quả hơn Integer(1) mới, nhưng mọi người đều đã biết điều đó.
Hoặc ví dụ này: Integer n = flag ? 1 : null; Tác giả muốn đặt null vào n nếu cờ không được đặt. Bạn có nghĩ nó sẽ hoạt động không? Đúng. Nhưng hãy phức tạp hóa mọi chuyện: Integer n = flag1 ? 1 : flag2 ? 2 : null; Có vẻ như không có nhiều khác biệt. Tuy nhiên, bây giờ nếu cả hai cờ đều rõ ràng thì dòng này sẽ ném ra một ngoại lệ NullPointerException. Các tùy chọn cho toán tử ba ngôi bên phải là int và null, do đó loại kết quả là Số nguyên. Các tùy chọn cho bên trái là int và Integer, vì vậy theo quy tắc Java, kết quả là int. Để thực hiện việc này, bạn cần thực hiện việc mở hộp bằng cách gọi intValue, thao tác này sẽ đưa ra một ngoại lệ. Mã này tương đương với điều này: Integer n; if( flag1 ) n = Integer.valueOf(1); else { if( flag2 ) n = Integer.valueOf(Integer.valueOf(2).intValue()); else n = Integer.valueOf(((Integer)null).intValue()); } Ở đây FindBugs tạo ra hai thông báo đủ để nghi ngờ có lỗi:
BX_UNBOXING_IMMEDIATELY_REBOXED: Giá trị được đóng hộp được bỏ hộp và sau đó được đóng hộp lại ngay lập tức trong TestTernary.main(String[]) NP_NULL_ON_SOME_PATH: Có thể hủy bỏ tham chiếu con trỏ null của null trong TestTernary.main(String[]) Có một nhánh câu lệnh, nếu được thực thi, đảm bảo rằng một giá trị null sẽ bị hủy đăng ký, điều này sẽ tạo ra ngoại lệ NullPointerException khi mã được thực thi.
Chà, một ví dụ cuối cùng về chủ đề này: double[] vals = new double[] {1.0, 2.0, 3.0}; double getVal(int idx) { return (idx < 0 || idx >= vals.length) ? null : vals[idx]; } Không có gì đáng ngạc nhiên khi mã này không hoạt động: làm thế nào một hàm trả về kiểu nguyên thủy có thể trả về null? Đáng ngạc nhiên là nó biên dịch mà không gặp vấn đề gì. Chà, bạn đã hiểu tại sao nó biên dịch rồi.

Định dạng ngày tháng

Để định dạng ngày và giờ trong Java, bạn nên sử dụng các lớp triển khai giao diện DateFormat. Ví dụ, nó trông như thế này: public String getDate() { return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()); } Thường thì một lớp sẽ sử dụng đi sử dụng lại cùng một định dạng. Nhiều người sẽ nảy ra ý tưởng tối ưu hóa: tại sao mỗi lần phải tạo một đối tượng định dạng khi bạn có thể sử dụng một phiên bản chung? private static final DateFormat format = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); public String getDate() { return format.format(new Date()); } Nó rất đẹp và hay nhưng tiếc là nó không hoạt động. Chính xác hơn là nó hoạt động, nhưng thỉnh thoảng bị hỏng. Thực tế là tài liệu về DateFormat nói:
Các định dạng ngày không được đồng bộ hóa. Nên tạo các phiên bản định dạng riêng cho từng luồng. Nếu nhiều luồng truy cập đồng thời vào một định dạng thì định dạng đó phải được đồng bộ hóa bên ngoài.
Và điều này đúng nếu bạn xem xét việc triển khai nội bộ của SimpleDateFormat. Trong quá trình thực thi phương thức format(), đối tượng ghi vào các trường của lớp, do đó việc sử dụng đồng thời SimpleDateFormat từ hai luồng sẽ dẫn đến một kết quả không chính xác với một số xác suất nhất định. Đây là những gì FindBugs viết về điều này:
STCAL_INVOKE_ON_STATIC_DATE_FORMAT_INSTANCE: Gọi phương thức tĩnh java.text.DateFormat trong TestDate.getDate() Như JavaDoc nêu rõ, DateFormats vốn không an toàn khi sử dụng đa luồng. Trình phát hiện đã tìm thấy lệnh gọi đến một phiên bản DateFormat được lấy thông qua trường tĩnh. Điều này có vẻ đáng ngờ. Để biết thêm thông tin về vấn đề này, hãy xem Lỗi mặt trời #6231579 và Lỗi mặt trời #6178997.

Cạm bẫy của BigDecimal

Sau khi biết rằng lớp BigDecimal cho phép bạn lưu trữ các số phân số có độ chính xác tùy ý và thấy rằng nó có một hàm tạo cho double, một số người sẽ quyết định rằng mọi thứ đều rõ ràng và bạn có thể làm như thế này: System.out.println(new BigDecimal( 1.1)); Không ai thực sự cấm làm điều này, nhưng kết quả có vẻ bất ngờ: 1.1000000000000000088817841970012523233890533447265625. Điều này xảy ra vì số nguyên thủy kép được lưu trữ ở định dạng IEEE754, trong đó không thể biểu diễn chính xác hoàn toàn 1.1 (trong hệ thống số nhị phân, thu được một phân số tuần hoàn vô hạn). Do đó, giá trị gần nhất với 1,1 được lưu trữ ở đó. Ngược lại, hàm tạo BigDecimal(double) hoạt động chính xác: nó chuyển đổi hoàn hảo một số đã cho ở dạng IEEE754 sang dạng thập phân (phân số nhị phân cuối cùng luôn được biểu thị dưới dạng số thập phân cuối cùng). Nếu bạn muốn biểu thị chính xác 1.1 dưới dạng BigDecimal thì bạn có thể viết BigDecimal("1.1") mới hoặc BigDecimal.valueOf(1.1). Nếu bạn không hiển thị số ngay lập tức mà thực hiện một số thao tác với nó, bạn có thể không hiểu lỗi xuất phát từ đâu. FindBugs đưa ra cảnh báo DMI_BIGDECIMAL_CONSTRUCTED_FROM_DOUBLE, đưa ra lời khuyên tương tự. Đây là một điều khác: BigDecimal d1 = new BigDecimal("1.1"); BigDecimal d2 = new BigDecimal("1.10"); System.out.println(d1.equals(d2)); Trên thực tế, d1 và d2 biểu thị cùng một số, nhưng bằng trả về sai vì nó không chỉ so sánh giá trị của các số mà còn so sánh cả thứ tự hiện tại (số vị trí thập phân). Điều này được viết trong tài liệu, nhưng sẽ ít người đọc tài liệu về một phương pháp quen thuộc như bằng. Một vấn đề như vậy có thể không phát sinh ngay lập tức. Thật không may, bản thân FindBugs không cảnh báo về điều này, nhưng có một tiện ích mở rộng phổ biến cho nó - fb-contrib, có tính đến lỗi này:
MDM_BIGDECIMAL_EQUALS bằng() được gọi để so sánh hai số java.math.BigDecimal. Đây thường là một sai lầm, vì hai đối tượng BigDecimal chỉ bằng nhau nếu chúng bằng nhau cả về giá trị và tỷ lệ, do đó 2.0 không bằng 2.00. Để so sánh các đối tượng BigDecimal về đẳng thức toán học, thay vào đó hãy sử dụng so sánhTo().

Ngắt dòng và printf

Thông thường các lập trình viên chuyển sang Java sau C rất vui khi khám phá PrintStream.printf (cũng như PrintWriter.printf , v.v.). Thật tuyệt, tôi biết rằng, giống như trong C, bạn không cần phải học bất cứ điều gì mới. Thực sự có sự khác biệt. Một trong số đó nằm ở bản dịch dòng. Ngôn ngữ C có sự phân chia thành các luồng văn bản và nhị phân. Việc xuất ký tự '\n' thành luồng văn bản bằng bất kỳ phương tiện nào sẽ tự động được chuyển đổi thành dòng mới phụ thuộc vào hệ thống ("\r\n" trên Windows). Không có sự phân tách như vậy trong Java: chuỗi ký tự chính xác phải được chuyển đến luồng đầu ra. Ví dụ: việc này được thực hiện tự động bằng các phương pháp thuộc họ PrintStream.println. Nhưng khi sử dụng printf, việc chuyển '\n' trong chuỗi định dạng chỉ là '\n', không phải là dòng mới phụ thuộc vào hệ thống. Ví dụ: hãy viết đoạn mã sau: System.out.printf("%s\n", "str#1"); System.out.println("str#2"); Sau khi chuyển hướng kết quả đến một tệp, chúng ta sẽ thấy: FindBugs giúp bạn học Java tốt hơn - 1 Vì vậy, bạn có thể nhận được một sự kết hợp kỳ lạ giữa các ngắt dòng trong một luồng, trông có vẻ cẩu thả và có thể khiến một số trình phân tích cú pháp phải suy nghĩ. Lỗi có thể không được phát hiện trong một thời gian dài, đặc biệt nếu bạn chủ yếu làm việc trên hệ thống Unix. Để chèn một dòng mới hợp lệ bằng printf, ký tự định dạng đặc biệt "%n" sẽ được sử dụng. Đây là những gì FindBugs viết về điều này:
VA_FORMAT_STRING_USES_NEWLINE: Chuỗi định dạng nên sử dụng %n thay vì \n trong TestNewline.main(String[]) Chuỗi định dạng này bao gồm ký tự dòng mới (\n). Trong các chuỗi định dạng, thường tốt hơn là sử dụng %n, điều này sẽ tạo ra dấu phân cách dòng dành riêng cho nền tảng.
Có lẽ, đối với một số độc giả, tất cả những điều trên đã được biết đến từ lâu. Nhưng tôi gần như chắc chắn rằng đối với họ sẽ có một cảnh báo thú vị từ máy phân tích tĩnh, cảnh báo này sẽ tiết lộ cho họ những tính năng mới của ngôn ngữ lập trình được sử dụng.
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION