Các Chủ Đề Tiến Bộ Trong C# part 8
lượt xem 5
download
Các mã không an toàn Có những trường hợp ta cần truy xuất bộ nhớ trực tiếp khi ta muốn truy xuất vào các hàm bên ngoài ( không thuộc .NET) mà đòi hỏi con trỏ được truyền vào như tham số( ví dụ như các hàm API ).hoặc là vì ta muốn truy nhập vào nội dung bộ nhớ để sửa lỗi...
Bình luận(0) Đăng nhập để gửi bình luận!
Nội dung Text: Các Chủ Đề Tiến Bộ Trong C# part 8
- Các mã không an toàn Có những trường hợp ta cần truy xuất bộ nhớ trực tiếp khi ta muốn truy xuất vào các hàm bên ngoài ( không thuộc .NET) mà đòi hỏi con trỏ được truyền vào như tham số( ví dụ như các hàm API ).hoặc là vì ta muốn truy nhập vào nội dung bộ nhớ để sửa lỗi....Trong phần này ta sẽ xem xét cách C# đáp ứng những điều này như thế nào. Con trỏ ( trình bày vắng tắt ) Con trỏ đơn giản là 1 biến lưu địa chỉ của một thứ khác theo cùng 1 cách như là 1 tham chiếu. sự khác biệt là cú pháp C# trong tham chiếu không cho phép ta truy xuất vào địa chỉ bộ nhớ. 3 ưu điểm của con trỏ : • Cải thiện sự thực thi : cho ta biết những gì ta đang làm,đảm bảo rằng dữ liệu được truy xuất hay thao tác theo cách hiệu quả nhất - đó là lí do mà C và C++ cho phép dung con trỏ trong ngôn ngữ của mình. • Khả năng tích hợp với các phần trước ( Backward compatibility ) - đôi khi ta phải sử dụng lại các hàm API cho mục đích của ta.Mà các hàm API được viết bằng C,ngôn ngữ dùng con trỏ rất nhiều, nghĩa là nhiều hàm lấy con trỏ như tham số.Hoặc là các DLL do 1 hãng nào đó cung cấp chứa các hàm lấy con trỏ làm tham số . Trong nhiều trường hợp ta có thể viết các khai báo DLlImport theo cách tránh sử dụng con trỏ , ví dụ như dùng lớp System.IntPtr. • Ta có thể cần tạo ra các địa chỉ vùng nhớ có giá trị cho người dùng - ví dụ nếu ta muốn phát triển 1 ứng dụng mà cho phép người dùng tương tác trực tiếp đến bộ nhớ, như là 1 debugger. Nhược điểm : • Cú pháp để lấy các hàm phức tạp hơn • Con trỏ khó sử dụng • Nếu không cẩn thận ta có thể viết lên các biến khác ,làm tràn stack, mất thông tin, đụng độ ... • C# có thể từ chối thi hành những đoạn mã không an toàn này (đoạn mã có sử dụng con trỏ) Ta có thể đánh dấu đoạn mã có sử dụng con trỏ bằng cách dùng từ khoá unsafe Ví dụ : dùng cho hàm
- unsafe int GetSomeNumber() { // code that can use pointers } Dùng cho lớp hay struct unsafe class MyClass { // any method in this class can now use pointers } Dùng cho 1 trường class MyClass { unsafe int *pX; // declaration of a pointer field in a class } Hoặc một khối mã void MyMethod() { // code that doesn't use pointers unsafe { // unsafe code that uses pointers here } // more 'safe' code that doesn't use pointers } Tuy nhiên ta không thể đánh dấu 1 biến cục bộ là unsafe int MyMethod() { unsafe int *pX; // WRONG } Để biên dịch các mã chứa khối unsafe ta dùng lệnh sau : csc /unsafe MySource.cs hay csc -unsafe MySource.cs Cú pháp con trỏ int * pWidth, pHeight; double *pResult;
- Lưu ý khác với C++ ,kí tự * kết hợp với kiểu hơn là kết hợp với biến - nghĩa là khi ta khai báo như ở trên thì pWidth và pHeight đều là con trỏ do có * sau kiểu int, khác với C++ ta phải khai báo * cho cả hai biến trên thì cả hai mới là con trỏ. Cách dùng * và & giống như trong C++ : & : lấy địa chỉ * : lấy nội dung của địa chỉ Ép kiểu con trỏ thành kiểu Int Vì con trỏ là 1 số int lưu địa chỉ nên ta có thể chuyển tường minh con trỏ thành kiểu int hay ngược lại.Ví dụ: int x = 10; int *pX, pY; pX = &x; pY = pX; *pY = 20; uint y = (uint)pX; int *pD = (int*)y; y là uint.sau đó ta chuyển ngược lại thành biến con trỏ pD 1 lý do để ta phải ép kiểu là Console.WriteLine không có overload nào nhận thông số là con trỏ do đó ta phải ép nó sang kiểu số nguyên int Console.WriteLine("Address is" + pX); // wrong - will give a // compilation error Console.WriteLine("Address is" + (uint) pX); // OK Ép kiểu giữa những kiểu con trỏ Ta cũng có thể chuyển đổi tường minh giữa các con trỏ trỏ đến1 kiểu khác ví dụ : byte aByte = 8; byte *pByte= &aByte; double *pDouble = (double*)pByte; void Pointers Nếu ta muốn giữ 1 con trỏ , nhưng không muốn đặc tả kiểu cho con trỏ ta có thể khai báo co ntrỏ là void: void *pointerToVoid; pointerToVoid = (void*)pointerToInt; // pointerToInt declared as int*
- mục đích là khi ta cần gọi các hàm API mà đòi hỏi thông số void*. Toán tử sizeof Lấy thông số là tên của kiểu và trả về số byte của kiểu đó ví dụ : int x = sizeof(double); x có giá trị là 8 Bảng kích thước kiểu : sizeof(sbyte) = 1; sizeof(byte) = 1; sizeof(short) = 2; sizeof(ushort) = 2; sizeof(int) = 4; sizeof(uint) = 4; sizeof(long) = 8; sizeof(ulong) = 8; sizeof(char) = 2; sizeof(float) = 4; sizeof(double) = 8; sizeof(bool) = 1; Ta cũng có thể dùng sizeof cho struct nhưng không dùng được cho lớp. Ví dụ PointerPlayaround Ví dụ sau trình bày cách thao tác trên con trỏ và trình bày kết quả, cho phép ta thấy những gì xảy ra trong bộ nhớ và nơi biến được lưu trữ: using System; namespace Wrox.ProCSharp.AdvancedCSharp { class MainEntryPoint { static unsafe void Main() { int x=10; short y = -1; byte y2 = 4; double z = 1.5; int *pX = &x; short *pY = &y; double *pZ = &z;
- Console.WriteLine( "Address of x is 0x{0:X}, size is {1}, value is {2}", (uint)&x, sizeof(int), x); Console.WriteLine( "Address of y is 0x{0:X}, size is {1}, value is {2}", (uint)&y, sizeof(short), y); Console.WriteLine( "Address of y2 is 0x{0:X}, size is {1}, value is {2}", (uint)&y2, sizeof(byte), y2); Console.WriteLine( "Address of z is 0x{0:X}, size is {1}, value is {2}", (uint)&z, sizeof(double), z); Console.WriteLine( "Address of pX=&x is 0x{0:X}, size is {1}, value is 0x{2:X}", (uint)&pX, sizeof(int*), (uint)pX); Console.WriteLine( "Address of pY=&y is 0x{0:X}, size is {1}, value is 0x{2:X}", (uint)&pY, sizeof(short*), (uint)pY); Console.WriteLine( "Address of pZ=&z is 0x{0:X}, size is {1}, value is 0x{2:X}", (uint)&pZ, sizeof(double*), (uint)pZ); *pX = 20; Console.WriteLine("After setting *pX, x = {0}", x); Console.WriteLine("*pX = {0}", *pX); pZ = (double*)pX; Console.WriteLine("x treated as a double = {0}", *pZ); Console.ReadLine(); } } } Mã gồm 3 biến • int x • short y • double z Cùng với các con trỏ trỏ đến các giá trị này.sau đó ta trình bày giá trị của các biến và kích thước,địa chỉ của nó.Ta dùng đặc tả {0:X} trong Console.WriteLine để địa chỉ bộ nhớ được trình bày theo định dạng số bát phân.
- Cuối cùng ta dùng con trỏ pX thay đổi giá trị của x thành 20,và thử ép kiểu biến x thành 1 double để xem điều gì sẻ xảy ra Biên dịch mã ,ta có kết quả sau : csc PointerPlayaround.cs Microsoft (R) Visual C# .NET Compiler version 7.00.9466 for Microsoft (R) .NET Framework version 1.0.3705 Copyright (C) Microsoft Corporation 2001. All rights reserved. PointerPlayaround.cs(7,26): error CS0227: Unsafe code may only appear if compiling with /unsafe csc /unsafe PointerPlayaround.cs Microsoft (R) Visual C# .NET Compiler version 7.00.9466 for Microsoft (R) .NET Framework version 1.0.3705 Copyright (C) Microsoft Corporation 2001. All rights reserved. PointerPlayaround Address of x is 0x12F8C4, size is 4, value is 10 Address of y is 0x12F8C0, size is 2, value is -1 Address of y2 is 0x12F8BC, size is 1, value is 4 Address of z is 0x12F8B4, size is 8, value is 1.5 Address of pX=&x is 0x12F8B0, size is 4, value is 0x12F8C4 Address of pY=&y is 0x12F8AC, size is 4, value is 0x12F8C0 Address of pZ=&z is 0x12F8A8, size is 4, value is 0x12F8B4 After setting *pX, x = 20 *pX = 20 x treated as a double = 2.63837073472194E-308 Pointer Arithmetic Ta có thể cộng hay trừ số nguyên trên con trỏ.Ví dụ , giả sử ta có 1 con trỏ trỏ đến số nguyên,và ta thử cộng 1 vào giá trị của nó .trình biên dịch sẽ biết và tăng vùng nhớ lên 4 byte ( do kiểu int có kích thước 4 byte).nếu là kiểu double thì khi cộng 1 sẽ tăng giá trị của con trỏ lên 8 byte. ta có thể dùng toán tử +, -, +=, -=, ++,và -- với biến bên phía phải của toán tử này là long hay ulong Ví dụ uint u = 3; byte b = 8;
- double d = 10.0; uint *pUint= &u; // size of a uint is 4 byte *pByte = &b; // size of a byte is 1 double *pDouble = &d; // size of a double is 8 Giả sử địa chỉ của những con trỏ này trỏ đến là : • pUint: 1243332 • pByte: 1243328 • pDouble: 1243320 sau khi thi hành ta có : ++pUint; // adds 1= 4 bytes to pUint pByte -= 3; // subtracts 3=3bytes from pByte double *pDouble2 = pDouble - 4; // pDouble2 = pDouble - 32 bytes (4*8 bytes) Con trỏ sẽ có giá trị: • pUint: 1243336 • pByte: 1243321 • pDouble2: 1243328 Ta cũng có thể trừ 2 con trỏ với nhau .giá trị kết quả là kiểu long bằng giá trị con trỏ chia cho kích thước của kiểu mà nó đại diện .Ví dụ : double *pD1 = (double*)1243324; // note that it is perfectly valid to // initialize a pointer like this. double *pD2 = (double*)1243300; long L = pD1-pD2; // gives the result 3 (=24/sizeof(double)) Con trỏ đến Struct - Toán tử truy xuất các thành viên con trỏ Cũng giống như con trỏ trong các kiểu dữ liệu có sẵn. tuy nhiên thêm 1 điều kiện là - Struct không chứa bất kì kiểu tham chiếu nào.Do con trỏ không thể trỏ đến bất kì kiểu tham chiếu nào. để tránh điều này , trình biên dịch sẽ phất cờ lỗi nếu ta tạo ra một con trỏ đến bất kì Struct nào chứa kiểu tham chiếu . Giả sử ta có struct như sau : struct MyGroovyStruct { public long X; public float F; }
- Sau đó ta định nghĩa con trỏ cho nó : MyGroovyStruct *pStruct; Khởi tạo nó : MyGroovyStruct Struct = new MyGroovyStruct(); pStruct = &Struct; Cũng có thể truy xuất các giá trị thành viên của 1 struct bằng con trỏ : (*pStruct).X = 4; (*pStruct).F = 3.4f; Tuy nhiên cú pháp này hơi phức tạp. C# định nghĩa 1 toán tử khác cho phép ta truy xuất các thành viên của Struct bằng con trỏ đơn giản hơn , gọi là toán tử truy xuất thành viên con trỏ ,kí hiệu là -> Cách dùng : pStruct->X = 4; pStruct->F = 3.4f; Ta cũng có thể thiết đặt trực tiếp con trỏ của kiểu tương đương để trỏ đến các trường trong Struct long *pL = &(Struct.X); float *pF = &(Struct.F); hay : long *pL = &(pStruct->X); float *pF = &(pStruct->F); Con trỏ đến các thành viên của lớp Ta đã nói rằng không thể tạo ra con trỏ đến lớp.vì việc tạo có thể làm cho bộ gom rác hoạt động không đúng. tuy nhiên ta có thể tạo các con trỏ đến các thành viên của lớp .Ta sẽ viết lại struct của ví dụ trước như là lớp : class MyGroovyClass { public long X; public float F; }
- sau đó ta có thể tạo 1 con trỏ đến các trường của nó ,X và F.tuy nhiên làm như vậy sẽ gây ra lỗi : MyGroovyClass myGroovyObject = new MyGroovyClass(); long *pL = &( myGroovyObject.X); // wrong float *pF = &( myGroovyObject.F); // wrong Do X và F nằm trong 1 lớp , mà được đặt trong heap.nghĩa là chúng vẫn gián tiếp chịu sự quản lý của bộ gom rác.cụ thể bộ gom rác có thể quyết định di chuyển MyGroovyClass đến 1 vị trí mới trong bộ nhớ để dọn dẹp heap.Nếu làm điều này thì bộ gom rác tất nhiên sẽ cập nhật tất cả các tham chiếu đến đối tượng ,giả sử như biến myGrooveObject vẫn sẽ trỏ đến đúng vị trí.Tuy nhiên bộ gom rác không biết gì về con trỏ cả. vì thế nếu di chuyển các đối tượng tham chiếu bởi myGrooveObject,pL và pF sẽ vẫn không thay đôỉ và kết cuộc là trỏ đến sai vị trí vùng nhớ. Để giải quyết vấn đề này ta dùng từ khóa fixed , mà cho bộ gom rác biết rằng có thể có con trỏ trỏ đến các thành viên của các thể hiện lớp,vì thế các thể hiện lớp này sẽ không được di chuyển.cú pháp như sau nếu ta chỉ muốn khai báo 1 con trỏ : MyGroovyClass myGroovyObject = new MyGroovyClass(); // do whatever fixed (long *pObject = &( myGroovyObject.X)) { // do something } nếu ta muốn khai báo nhiều hơn 1 con trỏ ta có thể đặt nhiều câu lệnh fixed trước khối mã giống nhau : MyGroovyClass myGroovyObject = new MyGroovyClass(); fixed (long *pX = &( myGroovyObject.X)) fixed (float *pF = &( myGroovyObject.F)) { // do something } Ta có thể lồng các khối fixed nếu ta muốn fix các con trỏ trong các thời điểm khác nhau MyGroovyClass myGroovyObject = new MyGroovyClass(); fixed (long *pX = &( myGroovyObject.X)) { // do something with pX fixed (float *pF = &( myGroovyObject.F))
- { // do something else with pF } } Ta cũng có thể khởi tạo vài biến trong cùng 1 khối fixed : MyGroovyClass myGroovyObject = new MyGroovyClass(); MyGroovyClass myGroovyObject2 = new MyGroovyClass(); fixed (long *pX = &( myGroovyObject.X), pX2 = &( myGroovyObject2.X)) { // etc. Thêm các lớp và Struct đến ví dụ Trong phần này ta sẽ minh họa việc tính toán trên con trỏ và các con trỏ đến struct và lớp .Ta dùng ví dụ 2, PointerPlayaround2: struct CurrencyStruct { public long Dollars; public byte Cents; public override string ToString() { return "$" + Dollars + "." + Cents; } } class CurrencyClass { public long Dollars; public byte Cents; public override string ToString() { return "$" + Dollars + "." + Cents; } } Bây giờ ta có thể áp dụng con trỏ cho các struct và lớp của ta .ta bắt đầu bằng việc trình bày kích thước của stuct , tạo ra 1 vài thể hiện của nó cùng với con trỏ.ta dùng những con trỏ này để khởi tạo 1 trong những struct Currency ,amount1. và trình bày các địa chỉ của các biến :
- public static unsafe void Main() { Console.WriteLine( "Size of Currency struct is " + sizeof(CurrencyStruct)); CurrencyStruct amount1, amount2; CurrencyStruct *pAmount = &amount1; long *pDollars = &(pAmount->Dollars); byte *pCents = &(pAmount->Cents); Console.WriteLine("Address of amount1 is 0x{0:X}", (uint)&amount1); Console.WriteLine("Address of amount2 is 0x{0:X}", (uint)&amount2); Console.WriteLine("Address of pAmt is 0x{0:X}", (uint)&pAmount); Console.WriteLine("Address of pDollars is 0x{0:X}", (uint)&pDollars); Console.WriteLine("Address of pCents is 0x{0:X}", (uint)&pCents); pAmount->Dollars = 20; *pCents = 50; Console.WriteLine("amount1 contains " + amount1); Ta biết rằng amount2 sẽ được lưu trữ ở 1 địa chỉ ngay sau amount1, sizeof ( CurrencyStru) trả về 16, vì vậy CurrencyStruct sẽ nằm ở địa chỉ là bội số của 4 byte.do đó sau khi giảm con trỏ currency , nó sẽ trỏ đến amount2: --pAmount; // this should get it to point to amount2 Console.WriteLine("amount2 has address 0x{0:X} and contains {1}", (uint)pAmount, *pAmount); Ta trình bày nội dụng của amount2 nhưng chưa khởi tạo nó .Dù trình biên dịch C# ngăn không cho chúng ta dùng các giá trị chưa được khởi tạo nhưng khi dùng con trỏ thì điều này không còn đúng nửa.trình biên dịch không cách nào biết nội của amount2 mà ta trình bày, chỉ có ta biết. kết tiếp ta sẽ tính toán trên con trỏ pCents,pCents hiện thời trỏ đến amount1.Cents , nhưng mục đích của ta là làm cho nó trỏ đến amount2.Cents .Làm điều này ta cần giảm địa chỉ của nó.ta cần làm một vài ép kiểu : // do some clever casting to get pCents to point to cents // inside amount2 CurrencyStruct *pTempCurrency = (CurrencyStruct*)pCents; pCents = (byte*) ( --pTempCurrency ); Console.WriteLine("Address of pCents is now 0x{0:X}", (uint)&pCents); Cuối cùng ta dùng vài từ khoá fixed để tạo ra một vài con trỏ mà trỏ đến các trường trong thể hiện lớp,và dùng những con trỏ này để thiết đặt giá trị của thể hiện này.Lưu ý rằng
- điều này cũng là lần đầu tiên ta thấy địa chỉ của một mục được lưu trữ trên heap hơn là trên stack: Console.WriteLine("\nNow with classes"); // now try it out with classes CurrencyClass amount3 = new CurrencyClass(); fixed(long *pDollars2 = &(amount3.Dollars)) fixed(byte *pCents2 = &(amount3.Cents)) { Console.WriteLine( "amount3.Dollars has address 0x{0:X}", (uint)pDollars2); Console.WriteLine( "amount3.Cents has address 0x{0:X}", (uint) pCents2); *pDollars2 = -100; Console.WriteLine("amount3 contains " + amount3); } chạy chương trình ta có : csc /unsafe PointerPlayaround2.cs Microsoft (R) Visual C# .NET Compiler version 7.00.9466 for Microsoft (R) .NET Framework version 1.0.3705 Copyright (C) Microsoft Corporation 2001. All rights reserved. PointerPlayaround2 Size of Currency struct is 16 Address of amount1 is 0x12F8A8 Address of amount2 is 0x12F898 Address of pAmt is 0x12F894 Address of pDollars is 0x12F890 Address of pCents is 0x12F88C amount1 contains $20.50 amount2 has address 0x12F898 and contains $5340121818976080.102 Address of pCents is now 0x12F88C Now with classes amount3.Dollars has address 0xBA4960 amount3.Cents has address 0xBA4968 amount3 contains $-100.0 Dùng con trỏ để tối ưu hoá thực thi
- Sau đây ta sẽ áp dụng những hiểu biết về con trỏ và minh họa 1 ví dụ mà ta thấy rõ lợi ích của việc dùng con trỏ trong thực thi Tạo ra mảng có nền là Stack Để tạo ra mảng này ta cần từ khoá stackalloc. lệnh stackalloc chỉ dẫn thời gian chạy .NET để cấp phát 1 số vùng nhớ trên stack khi ta gọi nó ,ta cần cung cấp cho nó 2 thông tin • Kiểu của biến mà ta muốn lưu trữ • Ta cần lưu bao nhiêu biến trong ví dụ , để cấp phát đủ vùng nhớ lưu trữ 10 số thập phân decimal , ta viết : decimal *pDecimals = stackalloc decimal [10]; lệnh này chỉ đơn giản cấp phát vùng nhớ. không khởi tạo bất kì giá trị nào. Để lưu 20 số double ta viết : double *pDoubles = stackalloc double [20]; mặc dù dòng mã này đặc tả số biến được lưu là hằng, điều này có thể là 1 định giá số lượng vào lúc chạy. vì thế ta có thể viết tương đương với ví dụ trên như sau : int size; size = 20; // or some other value calculated at run-time double *pDoubles = stackalloc double [size]; Kiểu mảng cơ bản nhất mà có thể có là 1 khối bộ nhớ lưu các phần tử như sau : Câu hỏi được đặt ra là làm thế nào ta sử dụng vùng nhớ mà ta vừa tạo.trở lại ví dụ ta vừa nói rằng giá trị trả về từ stackalloc trỏ đến bắt đầu của vùng nhớ.do đó cho phép ta có
- thể lấy vị trí đầu tiên của vùng nhớ được cấp phát.ví dụ để cấp phát các số double và thiết lập phần tử đầu tiên ( phàn tử 0 của mảng) giá trị 3.0 tacó thể viết : double *pDoubles = stackalloc double [20]; *pDoubles = 3.0; Ta có thể thiết lập phần tử thứ 2 của mảng bằng cách dùng cách tính toán trên con trỏ mà ta đã biết .Ví dụ nếu ta muốn đặt giá trị của phần tử thứ hai ta làm như sau : double *pDoubles = stackalloc double [20]; *pDoubles = 3.0; *(pDoubles+1) = 8.4; Nó chung ta có thể lấy phần tử thứ X của mảng với biểu thức *(pDoubles+X) Bên cạnh đó C# cũng định nghĩa 1 cú pháp thay thế .Nếu p là con trỏ và X là kiểu số thì biểu thức p[X] tương đương với *(p+X). double *pDoubles = stackalloc double [20]; pDoubles[0] = 3.0; // pDoubles[0] is the same as *pDoubles pDoubles[1] = 8.4; // pDoubles[1] is the same as *(pDoubles+1) Mặc dù mảng của ta có thể được truy xuất theo cùng cách như mảng bình thường, ta cần quan tâm đến cảnh báo sau . đoạn mã sau đây sẽ gây ra 1 biệt lệ: double [] myDoubleArray = new double [20]; myDoubleArray[50] = 3.0; Biệt lệ xuất hiện vì ta cố truy xuất vào mảng dùng chỉ mục vượt quá mảng ( chỉ mục là 50 , nhưng giá trị lớn nhất cho phép là 19).Tuy nhiên ,nếu ta khai báo 1 mảng dùng stackalloc , điều đó sẽ không gây ra biệt lệ : double *pDoubles = stackalloc double [20]; pDoubles[50] = 3.0; Ví dụ QuickArray Ta sẽ thảo luận về con trỏ với stackalloc trong ví dụ QuickArray. ví dụ hỏi người dùng bao nhiêu phần tử họ muốn cấp phát cho mảng. sau đó ta dùng stackalloc để cấp phát mảng với độ dài đó.các phần tử của mảng này được gán giá trị là bình phương của chỉ mục của nó .kết quả trình bày bên dưới : using System;
- namespace Wrox.ProCSharp.AdvancedCSharp { class MainEntryPoint { static unsafe void Main() { Console.Write("How big an array do you want? \n> "); string userInput = Console.ReadLine(); uint size = uint.Parse(userInput); long *pArray = stackalloc long [(int)size]; for (int i=0 ; i
- QuickArray
CÓ THỂ BẠN MUỐN DOWNLOAD
-
Bí kíp che giấu và bảo vệ dữ liệu riêng tư
4 p | 438 | 197
-
Các Chủ Đề Tiến Bộ Trong C# part
8 p | 75 | 7
-
Các Chủ Đề Tiến Bộ Trong C# part 2
11 p | 74 | 6
-
Các Chủ Đề Tiến Bộ Trong C# part 7
5 p | 94 | 5
-
Các Chủ Đề Tiến Bộ Trong C# part 9
1 p | 77 | 5
-
Các Chủ Đề Tiến Bộ trong C# part 6
4 p | 64 | 4
-
Các Chủ Đề Tiến Bộ Trong C# part 1
19 p | 71 | 4
-
Các Chủ Đề Tiến Bộ Trong C# part 3
11 p | 50 | 4
-
Các Chủ Đề Tiến Bộ Trong C# part 5
3 p | 94 | 3
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