YOMEDIA
ADSENSE
Essential CSharp 3rd Edition_3
47
lượt xem 3
download
lượt xem 3
download
Download
Vui lòng tải xuống để xem tài liệu đầy đủ
Tham khảo tài liệu 'essential csharp 3rd edition_3', công nghệ thông tin, kỹ thuật lập trình phục vụ nhu cầu học tập, nghiên cứu và làm việc hiệu quả
AMBIENT/
Chủ đề:
Bình luận(0) Đăng nhập để gửi bình luận!
Nội dung Text: Essential CSharp 3rd Edition_3
- 345 B oxing The result is that no copy back from the heap to the stack occurs. Instead, the modified heap data is ready for garbage collection while the data in angle remains unmodified. In the last case, the cast to IAngle occurs with the data on the heap already, so no copy occurs. MoveTo() updates the _Hours value and the code behaves as desired. ADVANCED TOPIC Unboxing Avoided As discussed earlier, the unboxing instruction does not include the copy back to the stack. Although some languages support the ability to access value types on the heap directly, this is possible in C# only when the value type is accessed as a field on a reference type. Since interfaces are reference types, unboxing and copying can be avoided, when accessing the boxed value via its interface. When you call an interface method on a value type, the instance must be a variable because the method might mutate the value. Since unboxing produces a managed address, the runtime has a storage location and hence a variable. As a result, the runtime simply passes that managed address on an interface and no unboxing operation is necessary. Listing 8.7 added an interface implementation to the Angle struct. List- ing 8.8 uses the interface to avoid unboxing. Listing 8.8: Avoiding Unboxing and Copying int number; object thing; number = 42; // Boxing thing = number; // No unbox instruction. string text = ((IFormattable)thing).ToString( "X", null); Console.WriteLine(text); Interfaces are reference types anyway, so calling an interface member does not even require unboxing. Furthermore, calling a struct’s ToString() method (that overrides object’s ToString() method) does not From the Library of Wow! eBook
- 346 C hapter 8: Value Types require an unbox. When compiling, it is clear that a struct’s overriding ToString() method will always be called because all value types are sealed. The result is that the C# compiler can instruct a direct call to the method without unboxing. Enums Compare the two code snippets shown in Listing 8.9. Listing 8.9: Comparing an Integer Switch to an Enum Switch int connectionState; // ... switch (connectionState) { case 0: // ... break; case 1: // ... break; case 2: // ... break; case 3: // ... break; } ConnectionState connectionState; // ... switch (connectionState) { case ConnectionState.Connected: // ... break; case ConnectionState.Connecting: // ... break; case ConnectionState.Disconnected: // ... break; case ConnectionState.Disconnecting: // ... break; } From the Library of Wow! eBook
- 347 E nums Obviously, the difference in terms of readability is tremendous because in the second snippet, the cases are self-documenting to some degree. How- ever, the performance at runtime is identical. To achieve this, the second snippet uses enum values in each case statement. An enum is a type that the developer can define. The key characteristic of an enum is that it identifies a compile-time-defined set of possible val- ues, each value referred to by name, making the code easier to read. You define an enum using a style similar to that for a class, as Listing 8.10 shows. Listing 8.10: Defining an Enum enum ConnectionState { Disconnected, Connecting, Connected, Disconnecting } NOTE An enum is helpful even for Boolean parameters. For example, a method call such as SetState(true) is less readable than SetState (DeviceState.On). You refer to an enum value by prefixing it with the enum name; to refer to the Connected value, for example, you use ConnectionState.Connected. You should not use the enum names within the enum value name, to avoid the redundancy of something such as ConnectionState.ConnectionStateCon- nected. By convention, the enum name itself should be singular, unless the enums are bit flags (discussed shortly). By default, the first enum value is 0 (technically, it is 0 implicitly con- verted to the underlying enum type), and each subsequent entry increases by one. However, you can assign explicit values to enums, as shown in Listing 8.11. From the Library of Wow! eBook
- 348 C hapter 8: Value Types Listing 8.11: Defining an Enum Type enum ConnectionState : short { Disconnected, Connecting = 10, Connected, Joined = Connected, Disconnecting } Disconnected has a default value of 0, Connecting has been explicitly assigned 10, and consequently, Connected will be assigned 11. Joined is assigned 11, the value referred to by Connected. (In this case, you do not need to prefix Connected with the enum name, since it appears within its scope.) Disconnecting is 12. An enum always has an underlying type, which may be int, uint, long, or ulong, but not char. In fact, the enum type’s performance is equivalent to that of the underlying type. By default, the underlying value type is int, but you can specify a different type using inheritance type syntax. Instead of int, for example, Listing 8.11 uses a short. For consistency, the syntax emulates that of inheritance, but this doesn’t actually make an inheritance relationship. The base class for all enums is System.Enum. Furthermore, these classes are sealed; you can’t derive from an existing enum type to add additional members. Successful conversion doesn’t work just for valid enum values. It is pos- sible to cast 42 into a ConnectionState, even though there is no corre- sponding ConnectionState enum value. If the value successfully converts to the underlying type, the conversion will be successful. The advantage to allowing casting, even without a corresponding enum value, is that enums can have new values added in later API releases, with- out breaking earlier versions. Additionally, the enum values provide names for the known values while still allowing unknown values to be assigned at runtime. The burden is that developers must code defensively for the possi- bility of unnamed values. It would be unwise, for example, to replace case ConnectionState.Disconnecting with default and expect that the only pos- sible value for the default case was ConnectionState.Disconnecting. Instead, you should handle the Disconnecting case explicitly and the Default case should report an error or behave innocuously. As indicated From the Library of Wow! eBook
- 349 E nums before, however, conversion between the enum and the underlying type, and vice versa, involves an explicit cast, not an implicit conversion. For example, code cannot call ReportState(10) where the signature is void Report- State(ConnectionState state). (The only exception is passing 0 because there is an implicit conversion from 0 to any enum.) The compiler will per- form a type check and require an explicit cast if the type is not identical. Although you can add additional values to an enum in a later version of your code, you should do this with care. Inserting an enum value in the middle of an enum will bump the values of all later enums (adding Flooded or Locked before Connected will change the Connected value, for example). This will affect the versions of all code that is recompiled against the new version. However, any code compiled against the old version will continue to use the old values, making the intended values entirely differ- ent. Besides inserting an enum value at the end of the list, one way to avoid changing enum values is to assign values explicitly. Enums are slightly different from other value types because enums derive from System.Enum before deriving from System.ValueType. Type Compatibility between Enums C# also does not support a direct cast between arrays of two different enums. However, there is a way to coerce the conversion by casting first to an array and then to the second enum. The requirement is that both enums share the same underlying type, and the trick is to cast first to Sys- tem.Array, as shown at the end of Listing 8.12. Listing 8.12: Casting between Arrays of Enums enum ConnectionState1 { Disconnected, Connecting, Connected, Disconnecting } enum ConnectionState2 { Disconnected, Connecting, From the Library of Wow! eBook
- 350 C hapter 8: Value Types Connected, Disconnecting } class Program { static void Main() { ConnectionState1[] states = (ConnectionState1[]) (Array) new ConnectionState2[42]; } } This exploits the fact that the CLR’s notion of assignment compatibility is more lenient than C#’s. (The same trick is possible for illegal conversions, such as int[] to uint[].) However, use this approach cautiously because there is no C# specification detailing that this should work across different CLR implementations. Converting between Enums and Strings One of the conveniences associated with enums is the fact that the ToString() method, which is called by methods such as System.Con- sole.WriteLine(), writes out the enum value identifier: System.Diagnostics.Trace.WriteLine(string.Format( "The Connection is currently {0}.", ConnectionState.Disconnecting)); The preceding code will write the text in Output 8.3 to the trace buffer. OUTPUT 8.3: The Connection is currently Disconnecting. Conversion from a string to an enum is a little harder to find because it involves a static method on the System.Enum base class. Listing 8.13 pro- vides an example of how to do it without generics (see Chapter 11), and Output 8.4 shows the results. From the Library of Wow! eBook
- 351 E nums Listing 8.13: Converting a String to an Enum Using Enum.Parse() ThreadPriorityLevel priority = (ThreadPriorityLevel)Enum.Parse( typeof(ThreadPriorityLevel), "Idle"); Console.WriteLine(priority); OUTPUT 8.4: Idle The first parameter to Enum.Parse() is the type, which you specify using the keyword typeof(). This is a compile-time way of identifying the type, like a literal for the type value (see Chapter 17). Until .NET Framework 4, there was no TryParse() method, so code prior to then should include appropriate exception handling if there is a chance the string will not correspond to an enum value identifier. .NET Framework 4’s TryParse() method uses generics, but the type parameters can be implied, resulting in the to-enum conversion example shown in Listing 8.14. Listing 8.14: Converting a String to an Enum Using Enum.TryParse() System.Threading.ThreadPriorityLevel priority; if(Enum.TryParse("Idle", out priority)) { Console.WriteLine(priority); } This conversion offers the advantage that there is no need to use exception handling if the string doesn’t convert. Instead, code can check the Boolean result returned from the call to TryParse(). Regardless of whether code uses the “Parse” or “TryParse” approach, the key caution about converting from a string to an enum is that such a cast is not localizable. Therefore, developers should use this type of cast only for messages that are not exposed to users (assuming localization is a requirement). Enums as Flags Many times, developers not only want enum values to be unique, but they also want to be able to combine them to represent a combinatorial value. From the Library of Wow! eBook
- 352 C hapter 8: Value Types For example, consider System.IO.FileAttributes. This enum, shown in Listing 8.15, indicates various attributes on a file: read-only, hidden, archive, and so on. The difference is that unlike the ConnectionState attri- bute, where each enum value was mutually exclusive, the FileAttributes enum values can and are intended for combination: A file can be both read-only and hidden. To support this, each enum value is a unique bit (or a value that represents a particular combination). Listing 8.15: Using Enums As Flags public enum FileAttributes { // 000000000000001 ReadOnly = 1
- 353 E nums System.IO.FileInfo file = new System.IO.FileInfo(fileName); file.Attributes = FileAttributes.Hidden | FileAttributes.ReadOnly; Console.WriteLine("{0} | {1} = {2}", FileAttributes.Hidden, FileAttributes.ReadOnly, (int)file.Attributes); if ( (file.Attributes & FileAttributes.Hidden) != FileAttributes.Hidden) { throw new Exception("File is not hidden."); } if (( file.Attributes & FileAttributes.ReadOnly) != FileAttributes.ReadOnly) { throw new Exception("File is not read-only."); } // ... } The results of Listing 8.16 appear in Output 8.5. OUTPUT 8.5: Hidden | ReadOnly = 3 Using the bitwise OR operator allows you to set the file to both read-only and hidden. In addition, you can check for specific settings using the bit- wise AND operator. Each value within the enum does not need to correspond to only one flag. It is perfectly reasonable to define additional flags that correspond to frequent combinations of values. Listing 8.17 shows an example. Listing 8.17: Defining Enum Values for Frequent Combinations enum DistributedChannel { None = 0, Transacted = 1, Queued = 2, From the Library of Wow! eBook
- 354 C hapter 8: Value Types Encrypted = 4, Persisted = 16, FaultTolerant = Transacted | Queued | Persisted } Furthermore, flags such as None are appropriate if there is the possibility that none is a valid value. In contrast, avoid enum values corresponding to things such as Maximum as the last enum, because Maximum could be inter- preted as a valid enum value. To check whether a value is included within an enum use the System.Enum.IsDefined() method. ADVANCED TOPIC FlagsAttribute If you decide to use flag-type values, the enum should include FlagsAt- tribute. The attribute appears in square brackets (see Chapter 17), just prior to the enum declaration, as shown in Listing 8.18. Listing 8.18: Using FlagsAttribute // FileAttributes defined in System.IO. [Flags] // Decorating an enum with FlagsAttribute. public enum FileAttributes { // 000000000000001 ReadOnly = 1
- 355 E nums file.Attributes = FileAttributes.Hidden | FileAttributes.ReadOnly; Console.WriteLine("\"{0}\" outputs as \"{1}\"", file.Attributes.ToString().Replace(",", " |"), file.Attributes); FileAttributes attributes = (FileAttributes) Enum.Parse(typeof(FileAttributes), file.Attributes.ToString()); Console.WriteLine(attributes); File.SetAttributes(fileName, startingAttributes); file.Delete(); } } The results of Listing 8.18 appear in Output 8.6. OUTPUT 8.6: "ReadOnly | Hidden" outputs as "ReadOnly, Hidden" ReadOnly, Hidden The flag documents that the enum values can be combined. Furthermore, it changes the behavior of the ToString() and Parse() methods. For exam- ple, calling ToString() on an enum that is decorated with FlagsAttribute writes out the strings for each enum flag that is set. In Listing 8.18, file.Attributes.ToString() returns ReadOnly, Hidden rather than the 3 it would have returned without the FileAttributes flag. If two enum val- ues are the same, the ToString() call would return the first value. As men- tioned earlier, however, you should use this with caution because it is not localizable. Parsing a value from a string to the enum also works. Each enum value identifier is separated by a comma. It is important to note that FlagsAttribute does not automatically assign unique flag values or check that they have unique values. Doing this wouldn’t make sense, since duplicates and combinations are often desir- able. Instead, you must assign the values of each enum item explicitly. From the Library of Wow! eBook
- 356 C hapter 8: Value Types SUMMARY This chapter began with a discussion of how to define custom value types. One of the key guidelines that emerge is to create immutable value types. Boxing also was part of the value type discussion. The idiosyncrasies introduced by boxing are subtle, and the vast major- ity of them lead to issues at execution time rather than at compile time. Although it is important to know about these in order to try to avoid them, in many ways, focused attention on the potential pitfalls overshadows the usefulness and performance advantages of value types. Programmers should not be overly concerned about using value types. Value types per- meate virtually every chapter of this book, and yet the idiosyncrasies do not. I have staged the code surrounding each issue to demonstrate the con- cern, but in reality, these types of patterns rarely occur. The key to avoid- ing most of them is to follow the guideline of not creating mutable value types; this is why you don’t encounter them within the primitive types. Perhaps the only issue to occur with some frequency is repetitive box- ing operations within loops. However, C# 2.0 greatly reduces the chance of this with the addition of generics, and even without that, performance is rarely affected enough to warrant avoidance until a particular algorithm with boxing is identified as a bottleneck. Furthermore, custom structs (value types) are relatively rare. They obviously play an important role within C# development, but when com- pared to the number of classes, custom structs are rare—when custom structs are required, it is generally in frameworks targeted at interoperat- ing with managed code or a particular problem space. In addition to demonstrating structs, this chapter introduced enums. This is a standard construct available in most programming languages, and it deserves prominent consideration if you want to improve API usability and code readability. The next chapter highlights more guidelines to creating well-formed types, both structs and otherwise. It begins by looking at overriding the virtual members of objects and defining operator-overloading methods. These two topics apply to both structs and classes, but they are somewhat more critical in completing a struct definition and making it well formed. From the Library of Wow! eBook
- 9 Well-Formed Types T covered most of the constructs for defining HE PREVIOUS CHAPTERS classes and structs. However, several details remain concerning rounding out the type definition with fit-and-finish-type functionality. This chapter introduces how to put the final touches on a type declaration. Finalizers Deterministic Finalization with the using Statement Resource 7 Overriding object Garbage Collection Cleanup 1 Members and Finalization Resource Utilization and Operator Finalization Guidelines 2 Overloading Garbage Well-Formed 6 Referencing Other 3 Collection T ypes A ssemblies Weak References Associating XML Comments XML Defining 5 4 Comments Namespaces with Programming Constructs Generating an XML Documentation File Overriding object Members Chapter 6 discussed how all types derive from object. In addition, it reviewed each method available on object and discussed how some of 357 From the Library of Wow! eBook
- 358 C hapter 9: Well-Formed Types them are virtual. This section discusses the details concerning overloading the virtual methods. Overriding ToString() By default, calling ToString() on any object will return the fully qualified name of the class. Calling ToString() on a System.IO.FileStream object will return the string System.IO.FileStream, for example. For some classes, however, ToString() can be more meaningful. On string, for example, ToString() returns the string value itself. Similarly, returning a Contact’s name would make more sense. Listing 9.1 overrides ToString() to return a string representation of Coordinate. Listing 9.1: Overriding ToString() public struct Coordinate { public Coordinate(Longitude longitude, Latitude latitude) { _Longitude = longitude; _Latitude = latitude; } public Longitude Longitude { get { return _Longitude; } } private readonly Longitude _Longitude; public Latitude Latitude { get { return _Latitude; } } private readonly Latitude _Latitude; public override string ToString() { return string.Format("{0} {1}", Longitude, Latitude); } // ... } Write methods such as Console.WriteLine() call an object’s ToString() method, so overloading it often outputs more meaningful information than the default implementation. Overriding GetHashCode() Overriding GetHashCode() is more complex than overriding ToString(). Regardless, you should override GetHashCode() when you are overriding Equals(), and there is a compiler warning to indicate this. Overriding From the Library of Wow! eBook
- 359 O verriding object M embers GetHashCode() is also a good practice when you are using it as a key into a hash table collection (System.Collections.Hashtable and System.Col- lections.Generic.Dictionary, for example). The purpose of the hash code is to efficiently balance a hash table by gener- ating a number that corresponds to the value of an object. Here are some implementation principles for a good GetHashCode() implementation. • Required: Equal objects must have equal hash codes (if a.Equals(b), then a.GetHashCode() == b.GetHashCode()). • Required: GetHashCode()’s returns over the life of a particular object should be constant (the same value), even if the object’s data changes. In many cases, you should cache the method return to enforce this. • Required: GetHashCode() should not throw any exceptions; GetHash- Code() must always successfully return a value. • Performance: Hash codes should be unique whenever possible. How - ever, since hash code returns only an int, there has to be an overlap in hash codes for objects that have potentially more values than an int can hold—virtually all types. (An obvious example is long, since there are more possible long values than an int could uniquely identify.) • Performance: The possible hash code values should be distributed evenly over the range of an int. For example, creating a hash that doesn’t consider the fact that distribution of a string in Latin-based languages primarily centers on the initial 128 ASCII characters would result in a very uneven distribution of string values and would not be a strong GetHashCode() algorithm. • Performance: GetHashCode() should be optimized for performance. GetHashCode() is generally used in Equals() implementations to short-circuit a full equals comparison if the hash codes are different. As a result, it is frequently called when the type is used as a key type in dictionary collections. • Performance: Small differences between two objects should result in large differences between hash code values—ideally, a 1-bit differ- ence in the object results in around 16 bits of the hash code changing, From the Library of Wow! eBook
- 360 C hapter 9: Well-Formed Types on average. This helps ensure that the hash table remains balanced no matter how it is “bucketing” the hash values. • Security: It should be difficult for an attacker to craft an object that has a particular hash code. The attack is to flood a hash table with large amounts of data that all hash to the same value. The hash table imple- mentation then becomes O(n) instead of O(1), resulting in a possible denial-of-service attack. Consider the GetHashCode() implementation for the Coordinate type shown in Listing 9.2. Listing 9.2: Implementing GetHashCode() public struct Coordinate { public Coordinate(Longitude longitude, Latitude latitude) { _Longitude = longitude; _Latitude = latitude; } public Longitude Longitude { get { return _Longitude; } } private readonly Longitude _Longitude; public Latitude Latitude { get { return _Latitude; } } private readonly Latitude _Latitude; public override int GetHashCode() { int hashCode = Longitude.GetHashCode(); // As long as the hash codes are not equal if(Longitude.GetHashCode() != Latitude.GetHashCode()) { hashCode ^= Latitude.GetHashCode(); // eXclusive OR } return hashCode; } // ... } Generally, the key is to use the XOR operator over the hash codes from the relevant types, and to make sure the XOR operands are not likely to be From the Library of Wow! eBook
- 361 O verriding object M embers close or equal—or else the result will be all zeroes. (In those cases where the operands are close or equal, consider using bitshifts and adds instead.) The alternative operands, AND and OR, have similar restrictions, but the restrictions occur more frequently. Applying AND multiple times tends toward all 0 bits, and applying OR tends toward all 1 bits. For finer-grained control, split larger-than-int types using the shift operator. For example, GetHashCode() for a long called value is imple- mented as follows: int GetHashCode() { return ((int)value ^ (int)(value >> 32)) }; Also, note that if the base class is not object, then base.GetHashCode() should be included in the XOR assignment. Finally, Coordinate does not cache the value of the hash code. Since each field in the hash code calculation is readonly, the value can’t change. However, implementations should cache the hash code if calculated val- ues could change or if a cached value could offer a significant performance advantage. Overriding Equals() Overriding Equals() without overriding GetHashCode() results in a warn- ing such as that shown in Output 9.1. OUTPUT 9.1: warning CS0659: ’’ overrides Object.Equals(object o) but does not override Object.GetHashCode() Generally, programmers expect overriding Equals() to be trivial, but it includes a surprising number of subtleties that require careful thought and testing. Object Identity versus Equal Object Values Two references are identical if both refer to the same instance. object, and therefore, all objects, include a static method called ReferenceEquals() that explicitly checks for this object identity (see Figure 9.1) From the Library of Wow! eBook
- 362 C hapter 9: Well-Formed Types 42 Equal Value Types 42 0x00A60289 Stack Equal Reference Types 0x00A64799 0x00A61234 Identical (Equal References) 0x00A61234 00 66 00 20 00 00 66 00 72 00 6F 00 6D 00 20 9C 11 C9 78 00 00 00 00 34 12 A6 00 00 00 00 00 33 00 00 00 00 00 00 00 00 Heap 00 00 00 00 00 00 00 00 00 00 D4 4C C7 78 02 41 00 20 00 63 00 61 00 63 00 6F 00 70 00 68 00 6F 00 6E 00 79 00 20 00 6F 00 66 00 20 00 72 00 61 00 6D D4 4C C7 78 02 Figure 9.1: Identity However, identical reference is not the only type of equality. Two object instances can also be equal if the values that identify them are equal. Con- sider the comparison of two ProductSerialNumbers shown in Listing 9.3. Listing 9.3: Equal public sealed class ProductSerialNumber { // See Appendix B } From the Library of Wow! eBook
- 363 O verriding object M embers class Program { static void Main() { ProductSerialNumber serialNumber1 = new ProductSerialNumber("PV", 1000, 09187234); ProductSerialNumber serialNumber2 = serialNumber1; ProductSerialNumber serialNumber3 = new ProductSerialNumber("PV", 1000, 09187234); // These serial numbers ARE the same object identity. if(!ProductSerialNumber.ReferenceEquals(serialNumber1, serialNumber2)) { throw new Exception( "serialNumber1 does NOT " + "reference equal serialNumber2"); } // and, therefore, they are equal else if(!serialNumber1.Equals(serialNumber2)) { throw new Exception( "serialNumber1 does NOT equal serialNumber2"); } else { Console.WriteLine( "serialNumber1 reference equals serialNumber2"); Console.WriteLine( "serialNumber1 equals serialNumber2"); } // These serial numbers are NOT the same object identity. if (ProductSerialNumber.ReferenceEquals(serialNumber1, serialNumber3)) { throw new Exception( "serialNumber1 DOES reference " + "equal serialNumber3"); } // but they are equal (assuming Equals is overloaded). else if(!serialNumber1.Equals(serialNumber3) || serialNumber1 != serialNumber3) { throw new Exception( "serialNumber1 does NOT equal serialNumber3"); } From the Library of Wow! eBook
- 364 C hapter 9: Well-Formed Types Console.WriteLine( "serialNumber1 equals serialNumber3" ); Console.WriteLine( "serialNumber1 == serialNumber3" ); } } The results of Listing 9.3 appear in Output 9.2. OUTPUT 9.2: serialNumber1 reference equals serialNumber2 serialNumber1 equals serialNumber3 serialNumber1 == serialNumber3 As the last assertion demonstrates with ReferenceEquals(), serial- Number1 and serialNumber3 are not the same reference. However, the code constructs them with the same values and both logically associate with the same physical product. If one instance was created from data in the database and another was created from manually entered data, you would expect the instances would be equal and, therefore, that the product would not be duplicated (reentered) in the database. Two identical references are obvi- ously equal; however, two different objects could be equal but not reference equal. Such objects will not have identical object identities, but they may have key data that identifies them as being equal objects. Only reference types can be reference equal, thereby supporting the concept of identity. Calling ReferenceEquals() on value types will always return false since, by definition, the value type directly contains its data, not a reference. Even when ReferenceEquals() passes the same variable in both (value type) parameters to ReferenceEquals(), the result will still be false because the very nature of value types is that they are copied into the parameters of the called method. Listing 9.4 demonstrates this behav- ior. In other words, ReferenceEquals() boxes the value types. Since each argument is put into a “different box” (location on the stack), they are never reference equal. Listing 9.4: Value Types Do Not Even Reference Equal Themselves public struct Coordinate { public Coordinate(Longitude longitude, Latitude latitude) { From the Library of Wow! eBook
ADSENSE
CÓ THỂ BẠN MUỐN DOWNLOAD
Thêm tài liệu vào bộ sưu tập có sẵn:
Báo xấu
LAVA
AANETWORK
TRỢ GIÚP
HỖ TRỢ KHÁCH HÀNG
Chịu trách nhiệm nội dung:
Nguyễn Công Hà - Giám đốc Công ty TNHH TÀI LIỆU TRỰC TUYẾN VI NA
LIÊN HỆ
Địa chỉ: P402, 54A Nơ Trang Long, Phường 14, Q.Bình Thạnh, TP.HCM
Hotline: 093 303 0098
Email: support@tailieu.vn