Trong C #, tại sao String là loại tham chiếu hoạt động giống như loại giá trị?


371

Chuỗi là một loại tham chiếu mặc dù nó có hầu hết các đặc tính của một loại giá trị như là bất biến và có == bị quá tải để so sánh văn bản thay vì đảm bảo chúng tham chiếu cùng một đối tượng.

Tại sao chuỗi không chỉ là một loại giá trị?


Vì đối với các loại không thay đổi, sự khác biệt chủ yếu là chi tiết triển khai (để iscác thử nghiệm sang một bên), câu trả lời có lẽ là "vì lý do lịch sử". Hiệu suất sao chép không thể là lý do vì không cần sao chép vật lý bất biến. Bây giờ không thể thay đổi mà không vi phạm mã thực sự sử dụng iskiểm tra (hoặc các ràng buộc tương tự).
Elazar

BTW đây là câu trả lời tương tự cho C ++ (mặc dù sự khác biệt giữa các loại giá trị và tham chiếu không rõ ràng trong ngôn ngữ), quyết định thực hiện std::stringhành vi như một bộ sưu tập là một lỗi cũ không thể sửa được.
Elazar

Câu trả lời:


333

Chuỗi không phải là loại giá trị vì chúng có thể rất lớn và cần được lưu trữ trên heap. Các loại giá trị là (trong tất cả các triển khai CLR cho đến nay) được lưu trữ trên ngăn xếp. Chuỗi phân bổ ngăn xếp sẽ phá vỡ tất cả mọi thứ: ngăn xếp chỉ 1MB cho 32 bit và 4 MB cho 64 bit, bạn phải đóng hộp từng chuỗi, phát sinh hình phạt sao chép, bạn không thể sử dụng chuỗi nội bộ và sử dụng bộ nhớ sẽ bóng bay, vv ...

(Chỉnh sửa: Đã thêm làm rõ về lưu trữ loại giá trị là một chi tiết triển khai, dẫn đến tình huống này khi chúng ta có một loại với các sơ đồ giá trị không kế thừa từ System.ValueType. Cảm ơn Ben.)


75
Tôi đang cố gắng ở đây, nhưng chỉ vì nó cho tôi cơ hội liên kết đến một bài đăng trên blog có liên quan đến câu hỏi: các loại giá trị không nhất thiết phải được lưu trữ trên ngăn xếp. Điều này thường đúng nhất trong ms.net, nhưng hoàn toàn không được chỉ định bởi đặc tả CLI. Sự khác biệt chính giữa các loại giá trị và tham chiếu là, các loại tham chiếu tuân theo ngữ nghĩa sao chép theo giá trị. Xem blog.msdn.com/ericlippert/archive/2009/04/27/ trên mạngblog.msdn.com/ericlippert/archive/2009/05/04/ trộm
Ben Schwehn

8
@Qwertie: Stringkhông phải là kích thước thay đổi. Khi bạn thêm vào nó, bạn thực sự đang tạo một Stringđối tượng khác , phân bổ bộ nhớ mới cho nó.
codekaizen

5
Điều đó nói rằng, về mặt lý thuyết, một chuỗi có thể là một loại giá trị (một cấu trúc), nhưng "giá trị" sẽ không có gì khác hơn là một tham chiếu đến chuỗi. Các nhà thiết kế .NET tự nhiên quyết định loại bỏ người trung gian (xử lý cấu trúc không hiệu quả trong .NET 1.0 và việc theo dõi Java là điều tự nhiên, trong đó các chuỗi đã được định nghĩa là một tham chiếu, thay vì kiểu nguyên thủy. Plus, nếu chuỗi là một loại giá trị sau đó chuyển đổi nó thành đối tượng sẽ yêu cầu nó được đóng hộp, không hiệu quả không cần thiết).
Qwertie

7
@codekaizen Qwertie đúng nhưng tôi nghĩ cách diễn đạt thật khó hiểu. Một chuỗi có thể có kích thước khác với một chuỗi khác và do đó, không giống như một loại giá trị thực, trình biên dịch không thể biết trước được bao nhiêu không gian để phân bổ để lưu trữ giá trị chuỗi. Chẳng hạn, một Int32luôn luôn là 4 byte, do đó trình biên dịch phân bổ 4 byte bất cứ khi nào bạn xác định một biến chuỗi. Trình biên dịch sẽ phân bổ bao nhiêu bộ nhớ khi nó gặp một intbiến (nếu đó là một loại giá trị)? Hiểu rằng giá trị chưa được chỉ định tại thời điểm đó.
Kevin Brock

2
Xin lỗi, một lỗi đánh máy trong bình luận của tôi mà tôi không thể sửa bây giờ; đó phải là .... Ví dụ, an Int32luôn là 4 byte, do đó trình biên dịch phân bổ 4 byte bất cứ khi nào bạn xác định một intbiến. Trình biên dịch sẽ phân bổ bao nhiêu bộ nhớ khi nó gặp một stringbiến (nếu đó là một loại giá trị)? Hiểu rằng giá trị chưa được chỉ định tại thời điểm đó.
Kevin Brock

57

Nó không phải là một loại giá trị bởi vì hiệu suất (không gian và thời gian!) Sẽ rất tệ nếu đó là một loại giá trị và giá trị của nó phải được sao chép mỗi khi nó được chuyển đến và trả về từ các phương thức, v.v.

Nó có giá trị ngữ nghĩa để giữ cho thế giới lành mạnh. Bạn có thể tưởng tượng việc viết mã sẽ khó như thế nào không

string s = "hello";
string t = "hello";
bool b = (s == t);

thiết lập bđược false? Hãy tưởng tượng việc mã hóa khó khăn như thế nào về bất kỳ ứng dụng nào.


44
Java không được biết đến là pithy.
jason

3
@Matt: chính xác. Khi tôi chuyển sang C #, điều này thật khó hiểu, vì tôi luôn sử dụng (đôi khi vẫn làm) .equals (..) để so sánh các chuỗi trong khi các đồng đội của tôi chỉ sử dụng "==". Tôi không bao giờ hiểu lý do tại sao họ không rời khỏi "==" để so sánh các tham chiếu, mặc dù nếu bạn nghĩ, 90% thời gian có thể bạn sẽ muốn so sánh nội dung không phải là các tham chiếu cho chuỗi.
Juri

7
@Juri: Trên thực tế tôi nghĩ rằng không bao giờ mong muốn kiểm tra các tài liệu tham khảo, vì đôi khi new String("foo");và một người khác new String("foo")có thể đánh giá trong cùng một tài liệu tham khảo, loại nào không phải là điều bạn mong đợi một newnhà điều hành sẽ làm. (Hoặc bạn có thể cho tôi biết một trường hợp mà tôi muốn so sánh các tài liệu tham khảo không?)
Michael

1
@Michael Vâng, bạn phải bao gồm một so sánh tham chiếu trong tất cả các so sánh để bắt so sánh với null. Một nơi tốt khác để so sánh các tham chiếu với các chuỗi, là khi so sánh chứ không phải so sánh bằng. Hai chuỗi tương đương, khi được so sánh sẽ trả về 0. Việc kiểm tra trường hợp này mặc dù mất nhiều thời gian bằng cách chạy qua toàn bộ so sánh, do đó không phải là một cách rút gọn hữu ích. Kiểm tra ReferenceEquals(x, y)là một bài kiểm tra nhanh và bạn có thể trả về 0 ngay lập tức và khi kết hợp với kiểm tra null của bạn thậm chí không thêm bất kỳ công việc nào nữa.
Jon Hanna

1
... có các chuỗi là một loại giá trị của kiểu đó thay vì là một loại lớp có nghĩa là giá trị mặc định của một chuỗi stringcó thể hoạt động như một chuỗi rỗng (như trong các hệ thống pre -.net) chứ không phải là một tham chiếu null. Trên thực tế, sở thích riêng của tôi sẽ là có một loại giá trị Stringchứa loại tham chiếu NullableString, với loại trước có giá trị mặc định tương đương String.Emptyvà loại sau có mặc định nullvà với các quy tắc đấm bốc / bỏ hộp đặc biệt (ví dụ như quyền anh mặc định- có giá trị NullableStringsẽ mang lại một tham chiếu đến String.Empty).
supercat

26

Sự khác biệt giữa các loại tham chiếu và các loại giá trị về cơ bản là một sự đánh đổi hiệu suất trong thiết kế ngôn ngữ. Các loại tham chiếu có một số chi phí xây dựng và phá hủy và thu gom rác, vì chúng được tạo ra trên đống. Mặt khác, các loại giá trị có phí trên các cuộc gọi phương thức (nếu kích thước dữ liệu lớn hơn một con trỏ), vì toàn bộ đối tượng được sao chép chứ không chỉ là một con trỏ. Vì các chuỗi có thể (và thường là) lớn hơn nhiều so với kích thước của một con trỏ, nên chúng được thiết kế dưới dạng các kiểu tham chiếu. Ngoài ra, như Servy đã chỉ ra, kích thước của một loại giá trị phải được biết tại thời điểm biên dịch, điều này không phải lúc nào cũng đúng với các chuỗi.

Câu hỏi về tính đột biến là một vấn đề riêng biệt. Cả hai loại tham chiếu và loại giá trị có thể là đột biến hoặc bất biến. Các loại giá trị thường không thay đổi, vì ngữ nghĩa cho các loại giá trị có thể thay đổi có thể gây nhầm lẫn.

Các loại tham chiếu thường có thể thay đổi, nhưng có thể được thiết kế là bất biến nếu nó có ý nghĩa. Các chuỗi được định nghĩa là bất biến bởi vì nó làm cho tối ưu hóa nhất định có thể. Ví dụ, nếu cùng một chuỗi ký tự xảy ra nhiều lần trong cùng một chương trình (khá phổ biến), trình biên dịch có thể sử dụng lại cùng một đối tượng.

Vậy tại sao "==" bị quá tải để so sánh các chuỗi theo văn bản? Bởi vì nó là ngữ nghĩa hữu ích nhất. Nếu hai chuỗi bằng nhau bằng văn bản, chúng có thể hoặc không thể là cùng một tham chiếu đối tượng do tối ưu hóa. Vì vậy, so sánh các tài liệu tham khảo là khá vô ích, trong khi so sánh văn bản hầu như luôn luôn là những gì bạn muốn.

Nói một cách tổng quát hơn, String có những gì được gọi là ngữ nghĩa giá trị . Đây là một khái niệm tổng quát hơn các loại giá trị, là một chi tiết triển khai cụ thể của C #. Các loại giá trị có ngữ nghĩa giá trị, nhưng các loại tham chiếu cũng có thể có ngữ nghĩa giá trị. Khi một loại có ngữ nghĩa giá trị, bạn thực sự không thể biết liệu triển khai cơ bản là loại tham chiếu hay loại giá trị, vì vậy bạn có thể xem đó là chi tiết triển khai.


Sự khác biệt giữa các loại giá trị và các loại tham chiếu thực sự không phải là về hiệu suất. Đó là về việc một biến có chứa một đối tượng thực tế hoặc một tham chiếu đến một đối tượng. Một chuỗi không bao giờ có thể là một loại giá trị vì kích thước của chuỗi là biến; nó sẽ cần phải là hằng số để trở thành một loại giá trị; hiệu suất gần như không có gì để làm với nó. Các loại tham khảo cũng không tốn kém để tạo ra.
Phục vụ

2
@Sevy: Kích thước của một chuỗi không đổi.
JacquesB

Bởi vì nó chỉ chứa một tham chiếu đến một mảng ký tự, có kích thước thay đổi. Có một loại giá trị chỉ có "giá trị" thực sự là một loại tham chiếu sẽ càng khó hiểu hơn, vì nó vẫn có ngữ nghĩa tham chiếu cho tất cả các mục đích chuyên sâu.
Phục vụ

1
@Sevy: Kích thước của một mảng là không đổi.
JacquesB

1
Khi bạn đã tạo một mảng, kích thước của nó là không đổi, nhưng tất cả các mảng trên toàn thế giới không phải là tất cả cùng một kích thước. Đó là quan điểm của tôi. Để một chuỗi là một loại giá trị, tất cả các chuỗi tồn tại sẽ cần phải có cùng kích thước, bởi vì đó là cách các loại giá trị được thiết kế trong .NET. Nó cần có khả năng dự trữ không gian lưu trữ cho các loại giá trị như vậy trước khi thực sự có giá trị , vì vậy kích thước phải được biết tại thời điểm biên dịch . Một stringloại như vậy sẽ cần phải có một bộ đệm char có kích thước cố định, sẽ vừa hạn chế vừa kém hiệu quả.
Phục vụ

16

Đây là một câu trả lời muộn cho một câu hỏi cũ, nhưng tất cả các câu trả lời khác đều thiếu điểm, đó là .NET không có khái quát cho đến .NET 2.0 vào năm 2005.

Stringlà loại tham chiếu thay vì loại giá trị vì nó có tầm quan trọng quan trọng đối với Microsoft để đảm bảo rằng các chuỗi có thể được lưu trữ theo cách hiệu quả nhất trong các bộ sưu tập không chung chung , chẳng hạn như System.Collections.ArrayList.

Lưu trữ một loại giá trị trong một bộ sưu tập không chung chung đòi hỏi phải chuyển đổi đặc biệt sang loại objectđược gọi là quyền anh. Khi CLR đóng hộp một loại giá trị, nó sẽ bọc giá trị bên trong a System.Objectvà lưu trữ nó trên heap được quản lý.

Đọc giá trị từ bộ sưu tập yêu cầu thao tác nghịch đảo được gọi là unboxing.

Cả quyền anh và unboxing đều có chi phí không đáng kể: quyền anh yêu cầu phân bổ bổ sung, unboxing yêu cầu kiểm tra loại.

Một số câu trả lời khẳng định không chính xác mà stringkhông bao giờ có thể được thực hiện dưới dạng loại giá trị vì kích thước của nó là biến. Trên thực tế, thật dễ dàng để thực hiện chuỗi dưới dạng cấu trúc dữ liệu có độ dài cố định bằng chiến lược Tối ưu hóa chuỗi nhỏ: chuỗi sẽ được lưu trữ trực tiếp trong bộ nhớ dưới dạng một chuỗi các ký tự Unicode ngoại trừ các chuỗi lớn sẽ được lưu trữ dưới dạng con trỏ tới bộ đệm ngoài. Cả hai biểu diễn có thể được thiết kế để có cùng độ dài cố định, tức là kích thước của một con trỏ.

Nếu khái quát đã tồn tại từ ngày đầu tiên, tôi đoán rằng có chuỗi như một loại giá trị có lẽ sẽ là một giải pháp tốt hơn, với ngữ nghĩa đơn giản hơn, sử dụng bộ nhớ tốt hơn và địa phương bộ đệm tốt hơn. Một List<string>chuỗi chỉ chứa các chuỗi nhỏ có thể là một khối bộ nhớ liền kề duy nhất.


Tôi, cảm ơn vì câu trả lời này! Tôi đã xem xét tất cả các câu trả lời khác nói những điều về phân bổ heap và stack, trong khi stack là một chi tiết triển khai . Rốt cuộc, stringchỉ chứa kích thước của nó và một con trỏ tới charmảng, vì vậy nó sẽ không phải là "loại giá trị lớn". Nhưng đây là một lý do đơn giản, có liên quan cho quyết định thiết kế này. Cảm ơn!
V0ldek

8

Không chỉ chuỗi là loại tham chiếu bất biến. Đại biểu nhiều diễn viên cũng vậy. Đó là lý do tại sao nó an toàn để viết

protected void OnMyEventHandler()
{
     delegate handler = this.MyEventHandler;
     if (null != handler)
     {
        handler(this, new EventArgs());
     }
}

Tôi cho rằng các chuỗi là bất biến bởi vì đây là phương pháp an toàn nhất để làm việc với chúng và phân bổ bộ nhớ. Tại sao chúng không phải là loại Giá trị? Các tác giả trước đây đúng về kích thước ngăn xếp, v.v. Tôi cũng sẽ thêm rằng việc tạo các chuỗi tham chiếu cho phép tiết kiệm kích thước lắp ráp khi bạn sử dụng cùng một chuỗi không đổi trong chương trình. Nếu bạn xác định

string s1 = "my string";
//some code here
string s2 = "my string";

Có thể cả hai trường hợp hằng số "chuỗi của tôi" sẽ được phân bổ trong hội đồng của bạn chỉ một lần.

Nếu bạn muốn quản lý các chuỗi như kiểu tham chiếu thông thường, hãy đặt chuỗi bên trong StringBuilder (chuỗi s) mới. Hoặc sử dụng MemoryStreams.

Nếu bạn định tạo một thư viện, nơi bạn mong đợi một chuỗi lớn được truyền vào các hàm của mình, hãy xác định tham số là StringBuilder hoặc dưới dạng Luồng.


1
Có rất nhiều ví dụ về các loại tham chiếu bất biến. Và đây là ví dụ về chuỗi, điều đó thực sự được đảm bảo khá nhiều theo các triển khai hiện tại - về mặt kỹ thuật, đó là trên mỗi mô-đun (không phải trên mỗi lắp ráp) - nhưng đó hầu như luôn là điều tương tự ...
Marc Gravell

5
Điểm cuối cùng: StringBuilder không giúp ích gì nếu bạn cố gắng vượt qua một chuỗi lớn (vì dù sao nó thực sự được triển khai như một chuỗi) - StringBuilder rất hữu ích để thao tác một chuỗi nhiều lần.
Marc Gravell

Ý của bạn là đại biểu xử lý chứ không phải hadler? (xin lỗi vì kén chọn .. nhưng nó rất gần với họ (không phổ biến) mà tôi biết ....)
Pure.Krom

6

Ngoài ra, cách các chuỗi được triển khai (khác nhau cho từng nền tảng) và khi bạn bắt đầu ghép chúng lại với nhau. Thích dùng a StringBuilder. Nó phân bổ một bộ đệm để bạn sao chép vào, khi bạn đạt đến cuối, nó sẽ cấp phát thêm bộ nhớ cho bạn, với hy vọng rằng nếu bạn thực hiện một hiệu suất ghép lớn sẽ không bị cản trở.

Có lẽ Jon Skeet có thể giúp đỡ ở đây?


5

Nó chủ yếu là một vấn đề hiệu suất.

Có các chuỗi hành xử kiểu giá trị THÍCH sẽ giúp khi viết mã, nhưng việc nó trở thành một loại giá trị sẽ tạo ra một hiệu suất rất lớn.

Để có cái nhìn sâu hơn, hãy xem qua một bài viết hay về các chuỗi trong khung .net.


3

Nói một cách đơn giản, bất kỳ giá trị nào có kích thước xác định đều có thể được coi là một loại giá trị.


Đây phải là một nhận xét
ρяσѕρєя K

dễ hiểu hơn cho ppl mới vào c #
LONG

2

Làm thế nào bạn có thể nói stringlà một loại tài liệu tham khảo? Tôi không chắc chắn rằng nó quan trọng như thế nào nó được thực hiện. Các chuỗi trong C # là bất biến chính xác để bạn không phải lo lắng về vấn đề này.


Đây là loại tham chiếu (tôi tin) vì nó không xuất phát từ System.ValueType Từ MSDN Nhận xét về System.ValueType: Kiểu dữ liệu được tách thành loại giá trị và loại tham chiếu. Các loại giá trị là phân bổ ngăn xếp hoặc phân bổ nội tuyến trong một cấu trúc. Các loại tham chiếu được phân bổ heap.
Davy8

Cả hai loại tham chiếu và giá trị đều được lấy từ Đối tượng lớp cơ sở cuối cùng. Trong trường hợp cần thiết cho một loại giá trị hoạt động như một đối tượng, một trình bao bọc làm cho loại giá trị trông giống như một đối tượng tham chiếu được phân bổ trên heap và giá trị của loại giá trị được sao chép vào nó.
Davy8

Trình bao bọc được đánh dấu để hệ thống biết rằng nó chứa một loại giá trị. Quá trình này được gọi là quyền anh, và quá trình ngược lại được gọi là unboxing. Quyền anh và unboxing cho phép bất kỳ loại nào được coi là một đối tượng. (Trong trang web hind, có lẽ chỉ nên liên kết với bài viết.)
Davy8

2

Trên thực tế, chuỗi có rất ít sự tương đồng với các loại giá trị. Đối với người mới bắt đầu, không phải tất cả các loại giá trị đều bất biến, bạn có thể thay đổi giá trị của một Int32 tất cả những gì bạn muốn và nó vẫn sẽ là cùng một địa chỉ trên ngăn xếp.

Các chuỗi là bất biến vì một lý do rất tốt, nó không liên quan gì đến nó là một kiểu tham chiếu, nhưng có liên quan nhiều đến việc quản lý bộ nhớ. Nó chỉ hiệu quả hơn khi tạo một đối tượng mới khi kích thước chuỗi thay đổi hơn là thay đổi mọi thứ xung quanh trên heap được quản lý. Tôi nghĩ rằng bạn đang trộn lẫn các loại giá trị / tham chiếu và các khái niệm đối tượng bất biến.

Theo như "==": Như bạn đã nói "==" là tình trạng quá tải toán tử và một lần nữa nó được triển khai vì một lý do rất chính đáng để làm cho khung công tác hữu ích hơn khi làm việc với chuỗi.


Tôi nhận ra rằng các loại giá trị không theo định nghĩa là bất biến, nhưng hầu hết thực tiễn tốt nhất dường như gợi ý rằng chúng nên có khi tạo của riêng bạn. Tôi đã nói các đặc điểm, không phải thuộc tính của các loại giá trị, mà theo tôi có nghĩa là các loại giá trị thường thể hiện các loại này, nhưng không nhất thiết phải theo định nghĩa
Davy8

5
@WebMatrix, @ Davy8: Các kiểu nguyên thủy (int, double, bool, ...) là bất biến.
jason

1
@Jason, tôi nghĩ thuật ngữ bất biến chủ yếu áp dụng cho các đối tượng (kiểu tham chiếu) không thể thay đổi sau khi khởi tạo, như chuỗi khi giá trị chuỗi thay đổi, bên trong một phiên bản mới của chuỗi được tạo và đối tượng ban đầu không thay đổi. Làm thế nào điều này áp dụng cho các loại giá trị?
WebMatrix

8
Bằng cách nào đó, trong "int n = 4; n = 9;", không phải biến int của bạn là "bất biến", theo nghĩa "không đổi"; Giá trị 4 là bất biến, nó không thay đổi thành 9. Biến int "n" của bạn trước tiên có giá trị là 4 và sau đó là một giá trị khác, 9; nhưng bản thân các giá trị là bất biến. Thành thật mà nói, với tôi điều này rất gần với wtf.
Daniel Daranas

1
+1. Tôi phát ngán khi nghe "chuỗi này giống như các loại giá trị" khi chúng hoàn toàn không đơn giản.
Jon Hanna

1

Không đơn giản như Chuỗi được tạo thành từ các mảng ký tự. Tôi xem các chuỗi như các mảng ký tự []. Do đó, chúng nằm trên heap vì vị trí bộ nhớ tham chiếu được lưu trữ trên ngăn xếp và trỏ đến điểm bắt đầu của vị trí bộ nhớ trên heap. Kích thước chuỗi không được biết trước khi được phân bổ ... hoàn hảo cho heap.

Đó là lý do tại sao một chuỗi thực sự bất biến bởi vì khi bạn thay đổi nó ngay cả khi nó có cùng kích thước, trình biên dịch không biết điều đó và phải phân bổ một mảng mới và gán các ký tự cho các vị trí trong mảng. Thật ý nghĩa nếu bạn nghĩ về các chuỗi như một cách mà các ngôn ngữ bảo vệ bạn khỏi phải phân bổ bộ nhớ một cách nhanh chóng (đọc C như lập trình)


1
"kích thước chuỗi không được biết trước khi được phân bổ" - điều này không chính xác trong CLR.
codekaizen

-1

Có nguy cơ nhận được một phiếu bầu bí ẩn khác ... thực tế là nhiều người đề cập đến ngăn xếp và bộ nhớ liên quan đến các loại giá trị và các kiểu nguyên thủy là bởi vì chúng phải phù hợp với một thanh ghi trong bộ vi xử lý. Bạn không thể đẩy hoặc bật một cái gì đó đến / từ ngăn xếp nếu cần nhiều bit hơn một thanh ghi có .... các hướng dẫn, ví dụ "pop eax" - bởi vì eax rộng 32 bit trên hệ thống 32 bit.

Các kiểu nguyên thủy dấu phẩy động được xử lý bởi FPU, rộng 80 bit.

Tất cả điều này đã được quyết định từ lâu trước khi có một ngôn ngữ OOP làm xáo trộn định nghĩa của kiểu nguyên thủy và tôi cho rằng loại giá trị là một thuật ngữ được tạo riêng cho các ngôn ngữ OOP.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.