Cho rằng các chuỗi là bất biến trong .NET, tôi tự hỏi tại sao chúng được thiết kế sao cho string.Substring()
mất substring.Length
thời gian O ( ) thay vì O(1)
?
tức là sự đánh đổi là gì, nếu có?
Cho rằng các chuỗi là bất biến trong .NET, tôi tự hỏi tại sao chúng được thiết kế sao cho string.Substring()
mất substring.Length
thời gian O ( ) thay vì O(1)
?
tức là sự đánh đổi là gì, nếu có?
Câu trả lời:
CẬP NHẬT: Tôi thích câu hỏi này rất nhiều, tôi chỉ viết blog nó. Xem Chuỗi, bất biến và kiên trì
Câu trả lời ngắn gọn là: O (n) là O (1) nếu n không phát triển lớn. Hầu hết mọi người trích xuất các chuỗi nhỏ từ các chuỗi nhỏ, vì vậy làm thế nào sự phức tạp tăng trưởng không có triệu chứng là hoàn toàn không liên quan .
Câu trả lời dài là:
Một cấu trúc dữ liệu bất biến được xây dựng sao cho các hoạt động trên một cá thể cho phép sử dụng lại bộ nhớ của bản gốc chỉ với một lượng nhỏ (thường là O (1) hoặc O (lg n)) sao chép hoặc phân bổ mới được gọi là "liên tục" cấu trúc dữ liệu bất biến. Chuỗi trong .NET là bất biến; Câu hỏi của bạn về cơ bản là "tại sao họ không kiên trì"?
Bởi vì khi bạn nhìn vào các hoạt động thường được thực hiện trên các chuỗi trong các chương trình .NET, thì theo mọi cách có liên quan hầu như không tệ hơn để tạo ra một chuỗi hoàn toàn mới. Chi phí và khó khăn trong việc xây dựng một cấu trúc dữ liệu liên tục phức tạp không phải trả cho chính nó.
Mọi người thường sử dụng "chuỗi con" để trích xuất một chuỗi ngắn - giả sử, mười hoặc hai mươi ký tự - trong chuỗi dài hơn một chút - có thể là vài trăm ký tự. Bạn có một dòng văn bản trong một tệp được phân tách bằng dấu phẩy và bạn muốn trích xuất trường thứ ba, đó là tên cuối cùng. Dòng sẽ có thể dài vài trăm ký tự, tên sẽ là vài chục. Phân bổ chuỗi và sao chép bộ nhớ năm mươi byte là nhanh đáng kinh ngạc trên phần cứng hiện đại. Việc tạo ra một cấu trúc dữ liệu mới bao gồm một con trỏ ở giữa một chuỗi hiện có cộng với độ dài cũng nhanh đến mức đáng kinh ngạc là không liên quan; "đủ nhanh" là theo định nghĩa đủ nhanh.
Các chất nền được chiết xuất thường có kích thước nhỏ và ngắn trong suốt cuộc đời; người thu gom rác sẽ sớm thu hồi chúng và họ đã không chiếm nhiều chỗ trong đống đầu tiên. Vì vậy, sử dụng một chiến lược bền bỉ khuyến khích tái sử dụng hầu hết bộ nhớ cũng không phải là một chiến thắng; tất cả những gì bạn đã làm là làm cho trình thu gom rác của bạn trở nên chậm hơn bởi vì bây giờ nó phải lo lắng về việc xử lý các con trỏ bên trong.
Nếu các hoạt động của chuỗi con mà mọi người thường làm trên các chuỗi là hoàn toàn khác nhau, thì sẽ có ý nghĩa với một cách tiếp cận bền bỉ. Nếu mọi người thường có chuỗi triệu ký tự và trích xuất hàng ngàn chuỗi con chồng chéo với kích thước trong phạm vi trăm nghìn ký tự và các chuỗi con đó tồn tại rất lâu trên đống, thì sẽ rất hợp lý khi đi theo chuỗi con liên tục tiếp cận; Sẽ thật lãng phí và dại dột phải không. Nhưng hầu hết các lập trình viên ngành kinh doanh không làm bất cứ điều gì thậm chí mơ hồ như những thứ đó. .NET không phải là một nền tảng phù hợp với nhu cầu của Dự án bộ gen người; Các lập trình viên phân tích DNA phải giải quyết các vấn đề với các đặc điểm sử dụng chuỗi đó mỗi ngày; tỷ lệ cược là tốt mà bạn không. Một số ít người xây dựng cấu trúc dữ liệu liên tục của riêng họ phù hợp chặt chẽ với các tình huống sử dụng của họ .
Ví dụ, nhóm của tôi viết các chương trình thực hiện phân tích nhanh chóng về mã C # và VB khi bạn nhập nó. Một số tệp mã đó rất lớn và do đó chúng tôi không thể thực hiện thao tác chuỗi O (n) để trích xuất chuỗi con hoặc chèn hoặc xóa ký tự. Chúng tôi đã xây dựng một loạt các cấu trúc dữ liệu dai dẳng bất biến cho đại diện chỉnh sửa vào đệm văn bản cho phép chúng tôi một cách nhanh chóng và hiệu quả tái sử dụng phần lớn các dữ liệu chuỗi đang tồn tại và phân tích từ vựng và cú pháp hiện khi một biên tập điển hình. Đây là một vấn đề khó giải quyết và giải pháp của nó được điều chỉnh phù hợp với miền cụ thể của chỉnh sửa mã C # và VB. Sẽ là không thực tế khi mong đợi loại chuỗi tích hợp để giải quyết vấn đề này cho chúng ta.
string contents = File.ReadAllText(filename); foreach (string line in content.Split("\n")) ...
hoặc các phiên bản khác của nó. Tôi có nghĩa là đọc toàn bộ tập tin, sau đó xử lý các phần khác nhau. Loại mã đó sẽ nhanh hơn đáng kể và cần ít bộ nhớ hơn nếu một chuỗi liên tục; bạn sẽ luôn có chính xác một bản sao của tệp trong bộ nhớ thay vì sao chép từng dòng, sau đó là các phần của mỗi dòng khi bạn xử lý nó. Tuy nhiên, như Eric đã nói - đó không phải là trường hợp sử dụng điển hình.
String
được triển khai như một cấu trúc dữ liệu bền vững (điều đó không được quy định trong các tiêu chuẩn, nhưng tất cả các triển khai tôi biết đều làm điều này).
Chính xác bởi vì Chuỗi là bất biến, .Substring
phải tạo một bản sao của ít nhất một phần của chuỗi gốc. Tạo một bản sao của n byte sẽ mất thời gian O (n).
Làm thế nào để bạn nghĩ rằng bạn sẽ sao chép một loạt các byte trong thời gian liên tục ?
EDIT: Mehrdad đề nghị không sao chép chuỗi nào cả, nhưng giữ một tham chiếu đến một đoạn của chuỗi.
Hãy xem xét trong .Net, một chuỗi nhiều megabyte, trên đó ai đó gọi .SubString(n, n+3)
(đối với bất kỳ n ở giữa của chuỗi).
Bây giờ, chuỗi ENTIRE không thể là Rác được thu thập chỉ vì một tham chiếu đang giữ đến 4 ký tự? Điều đó có vẻ như một sự lãng phí không gian vô lý.
Hơn nữa, theo dõi các tham chiếu đến các chuỗi con (thậm chí có thể nằm trong các chuỗi con) và cố gắng sao chép vào thời điểm tối ưu để tránh đánh bại GC (như mô tả ở trên), khiến khái niệm trở thành một cơn ác mộng. Nó đơn giản hơn nhiều, và đáng tin cậy hơn, để sao chép .SubString
và duy trì mô hình bất biến đơn giản.
EDIT: Đây là một ít đọc về sự nguy hiểm của việc giữ các tham chiếu đến các chuỗi con trong các chuỗi lớn hơn.
memcpy
mà vẫn là O (n).
char*
chuỗi con.
NULL
bị chấm dứt. Như đã giải thích trong bài đăng của Lippert , 4 byte đầu tiên chứa độ dài của chuỗi. Đó là lý do tại sao, như Skeet chỉ ra, chúng có thể chứa các \0
ký tự.
Java (trái ngược với .NET) cung cấp hai cách làm Substring()
, bạn có thể xem xét liệu bạn muốn giữ chỉ một tham chiếu hay sao chép toàn bộ chuỗi con vào một vị trí bộ nhớ mới.
Đơn giản .substring(...)
chia sẻ char
mảng được sử dụng nội bộ với đối tượng String ban đầu, sau đó bạn có new String(...)
thể sao chép sang một mảng mới, nếu cần (để tránh cản trở bộ sưu tập rác của đối tượng gốc).
Tôi nghĩ loại linh hoạt này là một lựa chọn tốt nhất cho một nhà phát triển.
.substring(...)
.
Java được sử dụng để tham chiếu các chuỗi lớn hơn, nhưng:
Tôi cảm thấy như nó có thể được cải thiện mặc dù: tại sao không chỉ sao chép một cách có điều kiện?
Nếu chuỗi con ít nhất bằng một nửa kích thước của cha mẹ, người ta có thể tham chiếu cha mẹ. Nếu không, người ta chỉ có thể tạo một bản sao. Điều này tránh rò rỉ rất nhiều bộ nhớ trong khi vẫn cung cấp một lợi ích đáng kể.
char[]
(với các con trỏ khác nhau để bắt đầu và kết thúc) để tạo ra một cái mới String
. Điều này rõ ràng cho thấy rằng phân tích lợi ích chi phí phải cho thấy một ưu tiên cho việc tạo ra một cái mới String
.
Không có câu trả lời nào ở đây đề cập đến "vấn đề đặt dấu ngoặc", nghĩa là các chuỗi trong .NET được biểu diễn dưới dạng kết hợp của BStr (độ dài được lưu trong bộ nhớ "trước" con trỏ) và CStr (chuỗi kết thúc bằng một '\ 0').
Do đó, chuỗi "Xin chào" được thể hiện dưới dạng
0B 00 00 00 48 00 65 00 6C 00 6F 00 20 00 74 00 68 00 65 00 72 00 65 00 00 00
(nếu được gán cho char*
mộtfixed
, con trỏ sẽ trỏ đến 0x48.)
Cấu trúc này cho phép tra cứu nhanh độ dài của chuỗi (hữu ích trong nhiều ngữ cảnh) và cho phép con trỏ được truyền trong API P / Gọi tới Win32 (hoặc loại khác) có chuỗi kết thúc null.
Khi bạn thực hiện Substring(0, 5)
quy tắc "oh, nhưng tôi đã hứa sẽ có một ký tự null sau ký tự cuối cùng" nói rằng bạn cần tạo một bản sao. Ngay cả khi bạn có chuỗi con ở cuối thì sẽ không có nơi nào để đặt độ dài mà không làm hỏng các biến khác.
Tuy nhiên, đôi khi, bạn thực sự muốn nói về "giữa chuỗi" và bạn không nhất thiết phải quan tâm đến hành vi P / Gọi. ReadOnlySpan<T>
Cấu trúc được thêm gần đây có thể được sử dụng để có được chuỗi con không sao chép:
string s = "Hello there";
ReadOnlySpan<char> hello = s.AsSpan(0, 5);
ReadOnlySpan<char> ell = hello.Slice(1, 3);
ReadOnlySpan<char>
" Chuỗi con" lưu trữ độ dài một cách độc lập và không đảm bảo rằng có '\ 0' sau khi kết thúc giá trị. Nó có thể được sử dụng theo nhiều cách "như một chuỗi", nhưng nó không phải là "một chuỗi" vì nó không có các đặc điểm BStr hoặc CStr (ít hơn cả hai). Nếu bạn không bao giờ (trực tiếp) P / Gọi thì sẽ không có nhiều sự khác biệt (trừ khi API bạn muốn gọi không bị ReadOnlySpan<char>
quá tải).
ReadOnlySpan<char>
không thể được sử dụng làm trường của loại tham chiếu, do đó, cũng có ReadOnlyMemory<char>
( s.AsMemory(0, 5)
), một cách gián tiếp để có một ReadOnlySpan<char>
, do đó, sự khác biệt tương tự string
tồn tại.
Một số câu trả lời / nhận xét về các câu trả lời trước đã nói về việc thật lãng phí khi người thu gom rác phải giữ một chuỗi ký tự hàng triệu ký tự trong khi bạn tiếp tục nói về 5 ký tự. Đó chính xác là hành vi bạn có thể nhận được với ReadOnlySpan<char>
cách tiếp cận. Nếu bạn chỉ thực hiện các tính toán ngắn, phương pháp ReadOnlySpan có lẽ tốt hơn. Nếu bạn cần duy trì nó trong một thời gian và bạn sẽ chỉ giữ một tỷ lệ nhỏ của chuỗi gốc, thực hiện một chuỗi con thích hợp (để cắt bớt dữ liệu dư thừa) có lẽ tốt hơn. Có một điểm chuyển tiếp ở đâu đó ở giữa, nhưng nó phụ thuộc vào cách sử dụng cụ thể của bạn.