iT邦幫忙

2021 iThome 鐵人賽

DAY 19
0
Software Development

三十天內用C++寫出一個小遊戲系列 第 19

Day 19 - C strings 字串,我好想吃串燒

Outline

  • Characters
  • C strings
  • C string processing functions

字串是由字元組成的。

指標的應用:字串

Characters (字元)

char

  • 利用 one byte (-128 ~ 127) 來儲存 英文字、數字、符號等等(我們在第 9 天有列給大家看)。
  • 它的骨子裡也是整數。

char 的使用:

  • 如果要使用 character literal,需要用單引號 (single quotation marks)

    int main()
    {
    	char c = '0';
    	cout << static_cast<int>(c) << " ";
    	c = 'A'
    	cout << static_cast<int>(c) << " ";
    	c = '\n'
    	cout << static_cast<int>(c) << " ";
    	return 0;
    }
    

    就跟上面講得一樣,因為 char 其實就是用整數來儲存符號數字等等,所以你把它 cast 成整數的時候就會變成一個數字 (也就是 ASCII code)。

  • 可以對 char 做加減:

    	int main()
    {
    	char c = 48; 
    	cout << c << " "; // 0
    	c += 10;
    	cout << c << " "; // :
    	if (c > 50)
    		cout << c << " "; // :
    	return 0;
    }
    
  • 第一個例子,如果當使用者輸入 'Y'或是'y' 就去執行,不是的話就不執行(重來)

    int main()
    {
    	int a = 0, b = 0;
    	char c = 0;
    
    	do
    	{
    		cout << "Enter 2 integers: ";
    		cin << a << b;
    		cout << "Add? ";
    		cin << c;	
    	} while (c != 'Y' && c != 'y');
    	cout << a + b << "\n";
    
    	return 0;
    }
    
  • 可以讓英文字母的大小寫互換

    // ASCII code: 
    // A : 65 -> B : 66 .... Z : 90
    // a : 97 -> b : 98 ....
    
    int main()
    {
    	char c = 0;
    	while(cin >> c)
    	{
    		if (c >= 65 && c <= 90)
    			cout << static_cast<char>(c + 32);
    		else
    			cout << c;
    		cout << "\n"; 
    	}
    	return 0;
    }
    

    但你怎麼可能會隨時記得 ASCII code 是甚麼啦,所以比較好的做法是使用 standard library : ,裡面就有很多函數可以幫你轉換:

    • int islower(int c); : 小寫的話: return 0;大寫: return nonzero。
    • int isupper(int c); : 大寫的話: return 0;小寫: return nonzero 。
    • int tolower(int c); : 轉換成小寫的數字(ASCII code)。
    • int toupper(int c); : 轉換成大寫的數字(ASCII code)。

    為什麼 return int : 因為在 C 語言的時候就是這樣,因此沿用

    • 那我們來印出 ASCII code 吧
    int main()
    {
    	cout << "   0123456789\n";
    	for (int i = 30; i <= 126; i++)
    	{
    		if (i % 10 == 0)
    			cout << setw(2) << i / 10 << " "; //setw(2) 控制欄位有兩格
    		if (isprint(i))
    			cout << static_cast<char>(i);
    		else
    			cout << " ";
    		if (i % 10 == 9)
    			cout << endl;
    	}
    	return 0;
    }
    

C strings

C strings - basics

String literal

在C語言裡面,有兩種 string:

  • C string 中使用的 character array (比較不好用)
  • C++ string 是使用 object (比較難用)

但是 C string 會使用到 pointer 的概念,可以加強我們對於 pointer 的使用經驗。

其實我們在很早很早以前,就已經用過 string 了

就是我們在用 cout << "Hello World!"; 的時候,雙引號中間這段東西,其實就是 string。

character array 因為也是 array,所以也可以像 array 一樣 initialize

```cpp
char s[10] = {0};
char t[10] = {'a', 'b', 'c'};
```

e.g.,

這段程式可以會讓你輸入,等到輸入'#'後或是輸入10個字元後就停止

```cpp
const int LEN = 10;
int main()
{
	char s[LEN] = {0};
	int n = 0;
	do 
	{
		cin >> s[n];
		n++;
	}while (s[n - 1] != '#' && n < LEN);
	for (int i = 0; i < n; i++)
		cout << s[i];
	return 0;
}
```

要怎麼 input string?

在C語言中,他們把存在 char array 裡面的 operation overloading(就是可以做出更多事,跟function 的 overloading 頗像)。

特別是 cin >> & cout <<

你可以直接 cin 一個 char array,

char str[10];
cin >> str; // if we type "abcde"
cout << str[0]; //a
cout << str[3];  //d 

或是可以直接 cout 一個 char array

int value[5];
cout << value; //an address
char str[10] = {0}; 
cin << str; // 如果我們打 "abcde"
cout << str; // 就會跑出 abcde

但是! 為什麼我們只打了 5 個 char,且 cout 出來也真的是 5 個? 可是我們原本宣告的陣列有 10 項欸,那我們怎麼知道這段陣列後面的是甚麼?

The null character: \0

當我們在 cin >> 的時候,當輸入到最後一項,電腦會自動在最後附加上一個 null character(\0 = 斜線零),來表示你的 cin 結束了。

  • null character 的 ASCII code 就是 0。

  • 這表示 null character 本質上也會佔一個空間, 因此你如果宣告了長度是 n 的 array,你最多只能存入 (n- 1)個 char。

  • C string 的 initialization ,可以寫成這樣

    char s[100] = "abc";

    這是因為 = 這個 operator 也被 overloaded 過了。(未來會講)

    也同樣的,在你宣告成 "abc" 的同時,電腦也會自動在最後加上 \0,以表示你宣告的東西就是這麼多。

    	char a[100] = "abcde FGH";
    	cout << a << "\n"; //abcde FGH
    	char b[100] = "abcde\0FGH";
    	cout << b << "\n";
    

    所以當我們做這件事的時候,會發現在 b[] 裡面,印完 abcde 就沒了,這就是因為電腦認為你印到 e就結束了。

  • 那 C string 的initialization 也可以寫成這樣:

    char s[100] = {'A', 'g', 'd'};
    

    但是這個時候,null character 卻不會被加到最後一項的後面

    這就是這兩種 宣告方式的不同了。

    那講了那麼多,到底甚麼時候會 append null character呢?

    舉幾個例子好了:

    • char s[10] = "abc" → 會 ✅
    • char s[100] = {'a', 'b', 'c'} → 不會 ❌
    • cin >> s; (直接輸入字串) → 會 ✅
    • cin >> s[0]; (一項一項輸入) → 不會 ❌

String assignments:

Assignment with double quotations are allowed only for Initialization

" " 的宣告,只能在 initialization 的時候做。

char s[100];
s = "this is a string"; // compilation error

但是如果是個別的做的話,就可以!

char s[100];
s[0] = 'A';
s[10] = 'B';

char c[100] = {0};
cin >> c; // 123456789
cin >> c;// abcde
cout << c << "\n"; // "abcde"
c[5] = '*'; 
cout << c << "\n"; // abcde*789

這個的原因是甚麼呢?

我們可以用圖表示:

【Cin 表示圖】

第幾次 cin or cout c[0] c[1] c[2] c[3] c[4] c[5] c[6] c[7] c[8] c[9]
first 0 0 0 0 0 0 0 0 0 0...
second 1 2 3 4 5 6 7 8 9 \0
third a b c d e \0 7 8 9 \0
fourth a b c d e 7 8 9 \0

因為 在輸入 abcde 的時候,它最後面也輸入了 \0,因此我們第三個cout 就只有印出 abcde ,但是我們在 assign * 給 s[5] 之後,\0消失了,所以就可以印出後面的東西了。

那如果我們輸入超出一個 array 的東西的話,則可能會有 error

char a[5] = {0};
cin >> a; //"123456789"
cout << //"123456789" or an error

所以可以知道 cout << 不會理你這個 array 裡面有多少東西,只會一直印,直到碰到 \0。

有個奇怪的例子:

char a1[100];
cin >> a1; // "this is an apple"
cout << a1; // "this"

為什麼只會輸出 this 呢?

還記得我們以前 cin 的時候,電腦會根據空白來切開我們輸入的不同的數字。

所以同理,輸入"this is an apple"的時候,就會只輸入 this 了!

那在 C++ 裡面,正確的使用是這個樣子:

char a[100];
cin.getline(a, 100); // Hi, this is John Cena
cout << a << "\n"; // Hi, this is John Cena

可以先把 cin.getline() 想像成一個函數,且在切換字元方面,它是依照換行來區別不同的東西。

(a, 100) → a 就是要傳入的陣列、100就是你想輸入的數量。

e.g.,

今天我們的陣列裡面有 100 項,我們要輸入一串字,讓這個程式數我們的字中到底有幾個 space (空白)

char a[100] = "abcde FGH";
	while (cin.getline(a, 100))
	{
		int i = 0;
		int spaceCount = 0;
		
		while (a[i] != '\0')
		{
		// 如果多個space只算一個的話
		//if (a[i] == ' ' && a[i - 1] == ' ')  
		//{
		//	i++;
		//	continue;
		//}
			if (a[i] == ' ')
				spaceCount ++;
			i++;
		}
		cout << spaceCount << "\n";	
	}

String array

如果你今天需要儲存很多個 string 的話,這時候就必須用一個二位陣列來儲存這麼多個 char array。

char name[4][10] = {"John", "Mikasa", "Eren", "Armin"};
cout << name << "\n"; // an address
cout << name[1] << "\n";  // Mikasa 
cin >> name[2]; // Obama
cout << name[2][0] << "\n"; // O

可以想像成:

第幾個 array [][0] [][1] [][2] [][3] [][4] [][5] [][6] [][7] [][8] [][9]
0 J o h n \0 ...
1 M i k a s a \0 ...
2 E r e n \0 ...
3 A r m i n \0 ...

C strings as character pointers

pointer 是可以指向一個 array 的,所以可以利用 character pointer 來使用 C string:

char a[100] = "12345";
char* p = s;
cout << p << "\n";
cin >> p; // or s
cout << s; // or p
  • 因為 pointer 只是指向一個地址,所以我們必須要先宣告一個 array(配置記憶體),才能使用 pointer。

  • 但是如果你 initialize pointer,就可以使用了! (但是這段空間只能讀不能做更改或是其他的宣告)

    char* p = "12345";
    cout << p + 2 << "\n"; // 345
    

Passing a string to a function?

要怎麼把 string 傳入 function 裡面? 其實很簡單,把你的 parameter 改成 pointer 或是 char array 就好了!

#include<iostream>
#include<cstring>
using namespace std;
void reverse(char p[]);
void print(char* p);

int main()
{
	char s[100] = "12345";
	reverse(&s[1]);
	print(s);
	return 0;
}

void reverse(char p[])
{
	int n = strlen(p); // string 的長度
	char* temp = new char[n];
	for (int i = 0; i < n; i++)
		temp[i] = p[n - i - 1];
	for (int i = 0; i < n; i++)
		p[i] = temp[i];
	delete [] temp; // 把空間釋放(避免 memory leak)
}

void print(char* p)
{
	cout << p << "\n";
}

我們想要寫一個程式,可以讓我們傳入的 string 可以反轉過來。

而且我們傳入的時候不一定要傳入 哪個位置,所以當然我們也可以傳 &s[1] (也就是第二項 2),這樣的話,結果就會顯示 15432 ,那如果改成 &s[2], 就會變成 12543 這樣!

小提醒: 你也可以用指標來宣告一段 "string",例如:

char* ptr = "12345";

這個時候,記憶體的操作就是把一塊空間空出來給你 放 "12345",但是你其實不知道在哪裡,接著再把 ptr 指在 "1" 上面。如此這般你就可以用 ptr 來讀取這段 string 了。

Main function arguments

如果你在 sublime 或是 visual studio 上面打 int ,他會給你選擇,如果你選擇的畫,他就會跑出:

int main(int argc, char* argv[])
{
	
}

你可能會發現 main function 裡面竟然有 argument,真的太奇葩了吧。

我原本根本搞不懂這個在幹嘛,所以就一直不敢用他。

在C++ 的 main function 裡面,只能傳入這兩個 argument

  • 第一個 int argc: 這個意思就是你會傳多少 argument (argc = argument count)
  • 後面的 char* argv[] 就是你傳入的字串指標(指向一條字串),argc 就是計算會有多少個字串會傳入
    • argv[0] 是 executable file 的名字
    • argv[i] 就是第 i 個傳入main中的 string

那我們來寫一個跟這些有關的程式:

int main(int argc, char* argv[])
{
	for (int i = 0; i < argc; i++)
	{
		cout << argv[i] << "\n";
	}	
	return 0
}

但是寫完如果你按編譯,他其實不會有任何的反應,所以你必須要打開終端機(win + R → cmd),然後按 cd(change direction) 找到你在的那個檔案資料夾(我的話是 Desktop),最後選擇 Untitled1.exe(我的檔案名稱) ,並在後面加上你想加入的string: 結果會這樣顯示:

C:\Users\user\Desktop>Untitled1.exe 1 2 3 4 t y u hgh gg
Untitled1.exe
1
2
3
4
t
y
u
hgh
gg

C:\Users\user\Desktop>

就可以看到第一個印出來的是 執行檔的名稱,接著後面的就是我傳進去的東西

但實際上我們不太會用到(可能要寫作業程式的時候會用到)。

C string processing functions

使用 c string 很方便的函數,這些函數都存在 <cstring>這個library 裡面,且有很多是 pointer-based 的 function。另外<cstdlib>裡面也有蠻多好用的函數。要查的話可以用 http://www.cplusplus.com/ 裡面可以查到很多東西。

  1. String length query

    strlen():

    • 功能:可以計算你的string 有多長。
    • 呼叫: unsigned int strlen(const char* str); // 這個函式不會影響你傳入的 str
    • 回傳: 一個 unsigned integer (計算從 str(第一個的地址)到第一個'\0' 中間會有幾個字元)
    - - - - - - - - - - -

    -|-|-|1|2|3|4|5|\0|-|-
    -|-|-|↑str|-|-|-|-|-|-|-

    就像是這樣

    E.g.

    char* p = new char[100];
    cin >> p;
    cout << strlen(p);
    p[3] = '\0';
    cout << strlen(p + 1) << "\n";  
    delete [] p;
    

    結果會 cout 2,原因是這樣的:

    如果我們原本輸入12345,就會自動在後面加上\0

    1 2 3 4 5 \0

    但我們之後把 p[3] 改成 '\0' 之後,就會變成:

    1 2 3 \0 5 \0

    所以當 strlen() 在偵測的時候就會發現 從 p+1 跑到 \0 只有兩個,所以就會顯示 2 了!

    簡單說,strlen()就是把你覺得是 string 的東西丟進去就可以數他的長度。所以像上面,若你把一個指標丟給它,它也會幫你數。

    • sizeof() 跟 strlen() 的區別

      char* p = "12345";
      	cout << strlen(p) << "\n"; // 
      	char a[100] = "1234567890";
      	cout << strlen(a) << "\n"; //   
      	cout << sizeof(a) << "\n"; // 
      	cout << sizeof(a + 2) << "\n"; // 8 -> 因為sizeof 偵測的是大小 a+2 會被認為是一個指標
      
    • 應用:

      還記得上面寫的找空格的程式嗎?其實有了 strlen(),我們就可以把 while 改成 for loop:

      char a[100] = "abcde FGH";
      	while (cin.getline(a, 100))
      	{
      		int i = 0;
      		int spaceCount = 0;
      		for(int i = 0; i < strlen(a); i++)
      		{
      			if (a[i] == ' '
      				spacecount++;
      		}
      		cout << spaceCount << "\n";	
      	}
      

      Likewise.

  2. searching in a string

    strchr()

    • 功能:可以在你的 string 中尋找某一個 character
    • 呼叫: char* strchr(char* str, int character) (給我一個 character,找看看有沒有存在 str 裡面,有的話就回傳他的位置,再加上心號,就會變成一串字串)
    • 回傳: 那一個 character 所在的位置,如果不存在就回傳 nullptr。
    • 這就是一個 pointer-based 的函式!
    char a[100] = "1234567890";
    char* p = strchr(a, '8');
    if (strchr(a, 'a') == nullptr)
    	cout << "!!!\n";
    cout << strchr(a, '4') << "\n";
    cout << strchr(a, '4') - a;
    

    結果會顯示:

    !!!

    4567890 : 這是因為函式回傳 4 的地址,再加上*就會變成它後面的一串字串了。

    3 :因為 4 與 1 差了三格

    如果想要驗證的話可以用下面這段:

    char* ptr = "1234";
    cout << ptr << "\n";//1234
    cout << ptr + 1 ; //234
    

    cout 一個指向string的指標的話,就會 cout 它後面剩下的 string。

    • 應用:打string裡的空白都換成底線。
    char a[100] = "This is a book.";
    	char* p = strchr(a, ' '); //指向第一個space
    	while(p != nullptr)
    	{
    		*p = '_'; //把space 換成underline
    			p = strchr(p, ' '); // 由上一個space當作起點找下一個
    	}
    	cout << a;
    
  3. searching for a substring:

    strstr():

    • 功能:可以在你的 string 中尋找某一個 string
    • 呼叫: char* strshr(char* str1, const char* str2) 傳入兩個 string
    • 回傳: 那一個 string 所在的位置,如果不存在就回傳 nullptr。
    • 這也是一個 pointer-based 的函式!
    • 應用:我們直接找到字串裡面的所有 is 換成 IS
    char a[100] = "this is a book";
    	char* p = strstr(a, "is");
    	while(p != nullptr)
    	{
    		*p = 'I'; // 並不是 p = "IS" 這樣會變成 IS\0 而且 p 會完全被改變
    		*(p + 1) = 'S';
    		p = strstr(p, "is");
    	}
    	cout << a;
    
  4. String-number conversion

    簡單說就是把 string 中的數字轉換成 真的數字。

    在cstdlib裡面有兩個:

    • int atoi(const char* str); array to integer

    • double atof(const char* str); array to float

      • 這兩個函式的使用前提:string 裡面都要是數字相關
      char a[100] = "1234";
      cout << atoi(a) * 2 << endl;
      char b[100] = "-12.34";
      cout << atof(b) * 2 << endl;
      

      結果會跑出:

      2468
      -24.68

    • char* itoa(int value, char*str, int base); integer to array

      e.g.

      char a[100] = {0};
      itoa(123, a, 2);
      cout << a << endl;
      itoa(123, a, 10)
      cout << a[2] << " " << a << endl;
      
  5. String comparisons

    • 功能:可以把 character 拿來排序,就是比較每個字元的 ASCII code

    • 呼叫:

      • int strcmp(const char* str1, const char* str2);
      • int strcmp(const char* str1, const char* str2, unsigned int num);
    • 回傳:

      • 若兩個 string 是一樣的→回傳 0 == 0
      • 若 str1 在 str2 前面→ 回傳一個負數 <0
      • 若 str1 在 str2 後面→ 回傳一個正數 >0
    • 使用:

      char a[100] = "the";
      char b[100] = "they";
      char c[100] = "them";
      cout << strcmp(a, b) << endl;
      cout << strcmp(b, c) << " " << strcmp(b, c, 2); //後面的2就是比較前兩個 char
      
  6. String copy

    • 功能:可以直接取代一串 substring(也就是改了很多個 char)

    • 呼叫:char* strcpy(char* dest, const char* source, unsigned int num); (把 source 的東西 copy 到 destination); 最後的 num 則是選source前面 前 num 個 char 傳入 dest

    • 回傳:char*(dest 的 address)

    • 使用:

      //instance1
      char a[100] = "watermelon";
      char b[100] = "orange";
      cout << a << "\n";
      strcpy(a, b)
      cout << a << "\n";
      
    • 需要注意: copy 完之後,只是把原本存在上面的東西取代,但如果後來的字比較短 → 原本字串後面的東西就會留下。像是上面的 copy 之後就會變成

      orange\0lon\0

    • 接著我們想要借剛剛的程式,來換"is"

      //instance2
      char a[100] = "this is an apple";
      char* p = strstr(a, "is");
      while(p != nullptr)
      {
      	strcpy(p, "IS");
      	p = strstr(p, "is")
      }
      cout << a;
      

      這個時候就只會跑出 thIS,原因是因為我們做 copy 的時候,IS後面加了 \0,所以cout只會讀到 \0 就結束,如果你去 cout << a[5]; ,就會發現它會跑出 剩下的 is an apple

  7. String Concatenation

    • 功能:把兩個字串串在一起(從\0開始把字串接上)

    • 呼叫:char* strcat(char* dest, const char* source, unsigned int num); (把 source 的東西 copy 到 destination);最後的 num 則是選source前面 前 num 個 char 傳入 dest

    • 回傳:char*(dest 的 address)

    • 使用:

      //instance1
      char a[100] = "watermelon";
      char b[100] = "orange";
      cout << a << "\n";
      strcat(a, b)
      cout << a << "\n";
      
      -

      w|a|t|e|r|m|e|l|o|n|\0
      o|r|a|n|g|e|\0||||

      - - - - - - - - - - - - - - - - - -

      w|a|t|e|r|m|e|l|o|n|\0|o|r|a|n|g|e|\0|

      用圖解釋就非常的清楚了!

    注意! :要準備足夠的空間,你 copy 或是 concatenation 的時候很可能會 踩到人家的地盤,也很可能會發生 run time error。因此要注意你的 destination要是一個陣列,不要只是一個 pointer 指向的空間。

Case Study:

  1. sorting names alphabetically

    • e.g.

      Before: John, Mikasa, Eren, Armin

      After: Armin, Eren, John, Mikasa

    • strategy:

      • using bubble sort:

        75469 → 57469 → 54769 → 54679

        這樣可以確認最右邊是最大 再去排左邊 兩個兩個比較→換→比較→換

      • comparison: using strcmp()

      • swapping: using strcpy()

      const int CNT = 4;
      const int LEN = 10;
      
      void swapName(char* n1, char* n2)
      {
      	char temp[LEN] = {0};
      	strcpy(temp, n1);
      	strcpy(n1, n2);
      	strcpy(n2, temp);
      }
      
      int main()
      {
      	char name[CNT][LEN] = {"John", "Mikasa", "Eren", "Armin"};
      
      	for (int i = 0; i < CNT; ++i)
      		for (int j = 0; j < CNT - i - 1; ++j)
      			if (strcmp (name[j], name[j + 1]) > 0) // 大於0代表 str1 在 str2 後面
      				swapName(name[j], name[j + 1]);
      
      	for (int i = 0; i < CNT; ++i)
      	{
      		cout << name[i] << " ";
      	}
      	return 0;
      }
      

      improvement :

      可以 swap pointers

      const int CNT = 4;
      const int LEN = 10;
      
      void swapPtr(char*& p1, char*& p2) //*&對指標參數做 call by reference
      {
      	char* temp = p1;
      	p1 = p2;
      	p2 = temp;
      }
      
      int main()
      {
      	char name[CNT][LEN] = {"John", "Mikasa", "Eren", "Armin"};
      	char* ptr[CNT] = {name[0], name[1], name[2], name[3]};
      	// 用指標陣列儲存 name 陣列的位置
      	for (int i = 0; i < CNT; ++i)
      		for (int j = 0; j < CNT - i - 1; ++j)
      			if (strcmp (ptr[j], ptr[j + 1]) > 0) // 大於0代表 str1 在 str2 後面
      				swapPtr(ptr[j], ptr[j + 1]);
      
      	for (int i = 0; i < CNT; ++i)
      	{
      		cout << ptr[i] << " ";
      	}
      	return 0;
      }
      
  2. Splitting a string into substrings

    區隔東西的人: delimiters

    被區隔開來的人:token

    input: www.im.ntu.edu.tw/~lckung/courses/PD16

    output: www im ntu edu tw ~lckung courses PD16

    當然可以一個一個來切,但是比較好的方式是:

    strtok():

    • 功能:
    • 宣告: char* strtok(char* str, const char* delimiters);
    const int CNT = 100;
    const int WORD_LEN = 50;
    const int SEN_LEN = 1000;
    
    int main()
    {
    	char url[SEN_LEN];
    	char delim[] = ".//";
    	char word[CNT][WORD_LEN] = {0};
    	int wordCnt = 0;
    	cin >> url;
    
    	char* start = strtok (url, delim);
    	while(start != nullptr)
    	{
    		strcpy(word[wordCnt], start);
    		wordCnt++;
    		start = strtok(nullptr, delim); // 傳nullptr ->電腦會找剛剛紀錄的那個起點,再繼續找
    	}
    
    	for (int i = 0; i < wordCnt; ++i)
    		cout << word[i] << " ";
    	return 0;
    }
    

My Opinion

pointer 延伸出來的應用真的好多....

而且應用上往往要圖像化才清楚..

希望我可以把它用的更熟一點 ?

Better and better.


上一篇
Day 18 - 指標不能亂指會出事
下一篇
Day 20 - Self-defined Data types(in C) 自訂資料型態
系列文
三十天內用C++寫出一個小遊戲30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言