Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

指標與字串

作者: D1stance

指標

一個一般的變數有名稱、型態、值。

如果現在有三個變數:

int i = 5;
double d = 3.14;
bool b = true;
名稱型態
iint5
ddouble3.14
bbooltrue

這些變數都會放在記憶體的某個位置,我們會稱這些位置為「位址」。

名稱型態位址
iint0x7ffdfb8c5
ddouble0x7ffdfb903.14
bbool0x7ffdfb98true

這個變數位址我們會用一個十六進位的數字來表示。

十六進位

十六進位是以 16 為基數的數字系統,使用 0-9 和 A-F 來表示數字。

  • 0-9 表示數字 0 到 9
  • A 表示 10
  • B 表示 11
  • C 表示 12
  • D 表示 13
  • E 表示 14
  • F 表示 15

當一個數字有前綴 0x 時,表示這個數字是十六進位的。 剛才變數 i 的位址 0x7ffdfb8c,就是一個十六進位的數字, 因為一個 int 變數佔有 4 個位元組 (Bytes), 所以 0x7ffdfb8c 這個位址的下一個變數的位址是 0x7ffdfb90, 而 0x7ffdfb90 的下一個變數的位址是 0x7ffdfb98, 這是因為 double 佔有 8 個位元組。

指標

指標是一種特別的變數,用來儲存其他變數的位址。

我們可以宣告一個指標變數來儲存 i 的位址:

int *ptr = &i;

* 表示這是一個指標變數,

這裡的 & 運算子是取位址運算子,它會返回變數 i 的位址,

指標變數 ptr 的型態是 int*,表示它是一個指向 int 型態的指標,

指標變數 ptr 現在儲存了 i 的位址 0x7ffdfb8c

名稱型態位址
iint0x7ffdfb8c5
ptrint*0x7ffdfb990x7ffdfb8c

指標有以下這幾種宣告的方式:

int *ptr;
int* ptr;
int * ptr;

這三種方式都是合法的,指標的型態是 int*,而 ptr 是指標變數的名稱,

如果有多個指標變數,可以這樣宣告:

int *ptr1, *ptr2, *ptr3;

你會發現,* 會出現在每個指標變數的前面,所以通常而言用第一種宣告方式會比較清楚。

如果你寫成這樣

int *ptr1, ptr2, ptr3;

這樣的話,ptr1 是一個指標變數,而 ptr2ptr3 是一般的 int 變數。

而取位址運算子 & 也可以單獨使用,不一定要搭配指標:

int i = 5;
cout << (&i) << '\n'; // 這樣會輸出 i 的位址

間接運算子 *

間接運算子 * 用來取得指標所指向的變數的值,也可以用它來實際更改指標所指向的變數的值。

int i = 5;
int *ptr = &i;
cout << *ptr << '\n'; // 這樣會輸出 i 的值 5
*ptr = 10; // 這樣會把 i 的值改成 10
cout << i << '\n'; // 這樣會輸出 i 的值 10

一般而言,我們複製變數之後,修改被複製的變數不會影響原本的變數,

int a = 5;
int b = a; // 複製 a 的值到 b
b = 10; // 修改 b 的值
cout << a << '\n'; // 這樣會輸出 a 的值 5

但如果我們使用指標,因為紀錄的是變數實際上在記憶體中的位址, 所以我們是直接對記憶體進行操作, 這樣就可以直接修改原本的變數。

我們也可以結合取址運算子 & 和間接運算子 * 來做操作:

int num = 10;
*(&num) = 20; // 這樣會把 num 的值改成 20
cout << num << '\n'; // 這樣會輸出 num 的值 20

雙重指標

既然指標是用來儲存其他變數的位址,那麼指標本身也可以有自己的位址, 所以指向指標的地址的變數就是雙重指標。

int i = 5;
int *ptr = &i; // ptr 是指向 i 的指標
int **dptr = &ptr; // dptr 是指向 ptr 的雙重指標
名稱型態位址
iint0x7ffdfb8c5
ptrint*0x7ffdfb990x7ffdfb8c
dptrint**0x7ffdfb9d0x7ffdfb99

多重指標的口訣

在很多 * 疊加下,你可能會分不清楚現在操作的對象究竟是一個值還是一個位址, 這裡筆者提供一個競程創隊元老壯滋學長的口訣:

  1. 把宣告時的型態補在前面
  2. 剩下的部分就是遮起來的部分之值

這樣可能有點模糊,我們提供一個例子幫助理解:

int i = 5;
int *ptr = &i; // ptr 是指向 i 的指標
int **dptr = &ptr; // dptr 是指向 ptr 的雙重指標
int ***tptr = &dptr; // tptr 是指向 dptr 的三重指標
int ****fptr = &tptr; // fptr 是指向 tptr 的四重指標

現在有一個操作的對象是 *fptr,我們可以這樣理解:

先把原本的 int **** 的型態寫上去,

變成 int ****fptr 你想知道 *fptr 的值是什麼,

就把 *fptr 遮起來,剩下的部分是 int ***,所以 *fptr 的值是 int *** 的值,也就是 tptr 的值。

參考 Reference &

參考是一個 C++ 特有的概念,有點像是幫變數創造一個別名, 這樣就可以用別名來操作原本的變數, 參考的宣告方式是使用 & 符號:

int i = 5;
int &ref = i; // ref 是 i 的參考
ref = 10; // 這樣會把 i 的值改成 10
cout << i << '\n'; // 這樣會輸出 i 的值 10

參考有幾個特點:

  1. 參考宣告時一定要指定一個已經存在的變數,不能宣告一個沒有初始值的參考。
  2. 宣告後不能修改指定的變數,參考一旦與變數綁定,就不能再改變。
  3. 參考不佔用額外的記憶體空間,它只是原變數的別名。
取址運算子 &參考 &
用來取得變數的位址用來創建變數的別名

只有宣告的時候在型態加上的 & 是參考,其他地方的 & 都是取址運算子。

小練習

int a = 10;
int b = 5;
int *c = &a;
b += *c;
*c = 12;
cout << a << " " << b << '\n';
答案 12 15

陣列與指標

當我們宣告了一個陣列 int arr[5];

這個陣列的名稱 arr 在大部分的時候除了代表陣列以外,有時也會轉型為指向陣列第一個元素的指標。

這意味著 arr 可以被視為一個指向 int 的指標,指向陣列的第一個元素。

如果你對 arr 使用取址運算子 &,你會得到陣列的位址:

int arr[5] = {1, 2, 3, 4, 5};
cout << arr << '\n'; // 這樣會輸出陣列的位址
cout << &arr << '\n'; // 這樣也會輸出陣列的位址

這兩個輸出是相同的,因為 arr&arr 都指向陣列的第一個元素。 不過,陣列的名稱 arr 並不是一個指標變數,它不能被重新指派到其他位址。

如果你對 arr + 1 使用取址運算子 &,你會得到陣列第二個元素的位址:

cout << (arr + 1) << '\n'; // 這樣會輸出陣列第二個元素的位址

這是因為 arr + 1 會指向陣列的第二個元素,這個位址是 arr 的位址加上 sizeof(int) 的大小。 如果你想要取得陣列的第一個元素的值,可以使用間接運算子 *

cout << *arr << '\n'; // 這樣會輸出陣列第一個元素的值

如果你想要取得陣列的第二個元素的值,可以使用 *(arr + 1)

cout << *(arr + 1) << '\n'; // 這樣會輸出陣列第二個元素的值

以此類推,因此 arr[i] 可以被視為 *(arr + i)

那麼二維陣列呢?

假設我們有一個二維陣列 arr[3][5],這個陣列有 3 行 5 列。

alt text

二維陣列其實在記憶體中是連續存放的,

alt text

因此,這時候 arr 的第一個元素,會是一個長度為 5 的一維陣列 arr[0], 而 arr[0] 的第一個元素 arr[0][0] 會是 arr[0] 的第一個元素。

所以我們可以推出下列的關係:

arr[i][j]
== *(*(arr + i) + j)
== *(*(&arr[i]) + j)
== *(arr[i] + j)
== *(&arr[i][j])

練習題

第一題

#include<bits/stdc++.h>
using namespace std;
int main(){
    int a;
    int *b;
    a = 4;
    b = &a;
    cout << ++*b << " " << a;
}
答案 5 5

第二題

#include<bits/stdc++.h>
using namespace std;
int main(){
    int a = 1;
    int *b, *c;
    b = &a;
    c = &a;
    *b = 2;
    *c = 3;
    cout << a;
}
答案 3

第三題

#include<bits/stdc++.h>
using namespace std;
int main(){
    int a = 10;
    int b = 5;
    int *c = &a;
    int **d = &c;
    *c = 8;
    *d = &b;
    *c = 12;
    *d = &a;
    **d -= 2;
    cout << a << " " << b;
}
答案 6 12

字元與字串

字元

字元是一種用來儲存語言資料 (字母、數字、符號等) 的資料型態, 佔有 1 個位元組 (Byte),每個字元都有一個對應的 ASCII 碼。 例如,字母 'A' 的 ASCII 碼是 65,'B' 的 ASCII 碼是 66。

alt text

在上表中,0 ~ 31 和 127 是不可顯示的控制字元, 而 32 ~ 126 是可顯示的字元。

我們可以使用標準輸出入流來輸出字元:

#include <iostream>
using namespace std;

int main()
{
    char c = 'A'; // 宣告一個字元變數 c
    cout << c << '\n'; // 輸出字元 c
    cout << (int)c << '\n'; // 輸出字元 c 的 ASCII 碼
    char d;
    cin >> d; // 從標準輸入讀取一個字元
    cout << d << '\n'; // 輸出讀取的字元
    cout << (int)d << '\n'; // 輸出讀取的字元的 ASCII 碼
    return 0;
}

因為字元本身其實就是數字,所以也可以用來做基礎的加減和比較運算:

#include <iostream>
using namespace std;
int main()
{
    char c = 'A';
    cout << c + 1 << '\n'; // 輸出 'B'
    cout << c - 1 << '\n'; // 輸出 '@'
    cout << c < 'B' << '\n'; // 輸出 1 (true)
    cout << c > 'B' << '\n'; // 輸出 0 (false)
    return 0;
}

cctype

C++ 提供了一些函式來處理字元,這些函式定義在 <cctype> 標頭檔中。 這些函式可以用來檢查字元的類型,例如是否為數字、字母、空白等。

isdigit(c) // 檢查 c 是否為數字
isalpha(c) // 檢查 c 是否為字母
isspace(c) // 檢查 c 是否為空白字元
islower(c) // 檢查 c 是否為小寫字母
isupper(c) // 檢查 c 是否為大寫字母

// 下列兩個函式用來轉換字元的大小寫,會回傳 ASCII 碼
tolower(c) // 將 c 轉換為小寫字母
toupper(c) // 將 c 轉換為大寫字母

範例

#include <iostream>
#include <cctype>
using namespace std;

int main()
{
    cout << isdigit('5') << '\n'; // 1 (true)
    cout << isalpha('A') << '\n'; // 1 (true)
    cout << toupper('a') << '\n'; // 65
    return 0;
}

字串

字元陣列

字串其實就是很多的字,那麼就會是一個字元陣列, 我們可以這樣宣告一個字元陣列:

char str[6] = "Hello"; // 字串長度為 5,最後一個字元是 '\0'

特別的是,字元陣列最後需要有一個特殊的字元 '\0',這個字元稱為「字串結束符號」,用來標記字串的結尾。 這樣我們就可以用 cout 輸出字串:

#include <iostream>
using namespace std;
int main()
{
    char str[6] = "Hello"; // 字串長度為 5,最後一個字元是 '\0'
    cout << str << '\n'; // 輸出字串
    return 0;
}

這裡有一個問題是,如果我們宣告的字元陣列長度不夠, 那麼可能會輸出奇怪的結果,因為沒有足夠的空間來存放 '\0'

fgets

一般的 cin 只能讀取到空白字元為止,空白後的字串就會被視為新的輸入或者被忽略, 如果我們想要讀取包含空白的字串,可以使用 fgets 函式:

#include <iostream>
#include <cstdio> // 包含 fgets 函式
using namespace std;
int main()
{
    char str[100]; // 宣告一個字元陣列
    fgets(str, 10, stdin); // 從標準輸入讀取字串
    cout << str; // 輸出字串
    return 0;
}

要注意的事情是,第二個參數是字元陣列的大小, 還有不能跟 cin 一起使用, 因為 cin 會清除緩衝區中的換行字元。 以及他會把讀到的換行字元 \n 也包含在字串中, 如果不想要換行字元,可以在輸出前手動去掉:

str[strlen(str) - 1] = '\0'; // 去掉最後的換行字元

strlen 是一個 <cstring> 標頭檔中的函式,用來計算字串的長度, 回傳值不包含結束符號 '\0'

cstring 標頭檔還提供了其他一些有用的函式來處理字串,例如:

  • strcpy 用來複製字串
  • strcat 用來連接字串
  • strcmp 用來比較字串

首先講回 strlen,他有點像是你想知道一本書的頁數, 就從第一頁開始數,直到最後一頁的結束符號, 這樣就可以知道這本書有多少頁。

所以不建議放在迴圈中的判斷式,可能會造成超時。

#include <iostream>
#include <cstring> // 包含 strlen 函式
using namespace std;
int main()
{
    char str[100]; // 宣告一個字元陣列
    cin >> str;
    for(int i = 0; i < strlen(str); i++) // 使用 strlen 計算字串長度
        cout << str[i] << " "; // 輸出每個字元
    cout << '\n';

上面這個就是一個不好的範例,因為 strlen 會在每次迴圈中都計算一次字串長度, 這樣會造成不必要的計算,尤其是當字串很長時,會影響效能。 因此,建議在迴圈外先計算一次字串長度,然後在迴圈中使用這個長度:

#include <iostream>
#include <cstring> // 包含 strlen 函式
using namespace std;
int main()
{
    char str[100]; // 宣告一個字元陣列
    cin >> str;
    int len = strlen(str); // 在迴圈外計算一次字串長度
    for(int i = 0; i < len; i++) // 使用計算好的長度
        cout << str[i] << " "; // 輸出每個字元
    cout << '\n';
}

畢竟字串長度不會改變,所以只需要計算一次就可以了。

strcpy

strcpy 用來複製字串,語法如下:

char *strcpy(char *dest, const char *src);

dest 是目標字串,src 是來源字串,這個函式會把 src 的內容複製到 dest 中。

#include <iostream>
#include <cstring> // 包含 strcpy 函式
using namespace std;
int main()
{
    char str1[100] = "Hello"; // 來源字串
    char str2[100]; // 目標字串
    strcpy(str2, str1); // 複製 str1 到 str2
    cout << str2 << '\n'; // 輸出目標字串
    return 0;
}

但要注意的事情是,strcpy 不會檢查目標字串的大小, 如果目標字串的大小不夠,可能會導致緩衝區溢位 (Buffer Overflow), 簡單來說就是目標字串的空間不夠大,會導致寫入超出範圍的記憶體。 因此在使用 strcpy 時,必須確保目標字串有足夠的空間來存放來源字串。

strcat

strcat 用來連接兩個字串,語法如下:

char *strcat(char *dest, const char *src);

dest 是目標字串,src 是來源字串,這個函式會把 src 的內容連接到 dest 的末尾。

#include <iostream>
#include <cstring> // 包含 strcat 函式
using namespace std;
int main()
{
    char str1[100] = "Hello"; // 來源字串
    char str2[100] = " World"; // 目標字串
    strcat(str1, str2); // 將 str2 連接到 str1
    cout << str1 << '\n'; // 輸出連接後的字串
    return 0;
}

strcpy 一樣,strcat 也不會檢查目標字串的大小, 如果目標字串的大小不夠,可能會導致緩衝區溢位 (Buffer Overflow)。

strcmp

strcmp 用來比較兩個字串,語法如下:

int strcmp(const char *str1, const char *str2);

str1str2 是要比較的兩個字串,這個函式會返回一個整數值,

  • 如果 str1 小於 str2,則返回負值
  • 如果 str1 等於 str2,則返回 0
  • 如果 str1 大於 str2,則返回正值

這個函數會從第一個字元開始一一比較, 直到遇到不同的字元或是結束符號 '\0' 為止。

比較的結果是根據字元的 ASCII 碼來決定的, 假設一樣都是小寫,則照 az 的順序比較, 如果一個大寫一個小寫,則大寫會被視為小於小寫, 這種順序又被稱為 字典序 (Lexicographical Order)。

C++ 字串類別

Character array 比較像是 C 語言的做法, C++ 提供了一個更方便的字串類別 std::string, 這個類別提供了許多方便的函式來處理字串。

我們有以下幾種宣告 std::string 的方式:

string str1; // 宣告一個空的字串
string str2 = "Hello"; // 宣告並初始化字串
string str3("World"); // 使用括號初始化字串
string str4 = str2; // 複製字串
string str5 = char_array; // 從字元陣列初始化字串

輸入和輸出字串可以直接使用 cincout

#include <iostream>
#include <string> // 包含 string 類別
using namespace std;
int main()
{
    string str; // 宣告一個字串變數
    cin >> str; // 從標準輸入讀取字串
    cout << str << '\n'; // 輸出字串
    return 0;
}

一樣的問題,cin 會在遇到空白字元時停止讀取, 如果想要讀取包含空白的字串,可以使用 getline 函式:

#include <iostream>
#include <string> // 包含 string 類別
using namespace std;
int main()
{
    string str; // 宣告一個字串變數
    getline(cin, str); // 從標準輸入讀取包含空白的字串
    cout << str << '\n'; // 輸出字串
    return 0;
}

getline 會讀取整行輸入,直到遇到換行字元為止, 不同的於 fgetsgetline 不會包含換行字元, 特別的是,我們也可以調整他要遇到什麼字元才停止讀取:

#include <iostream>
#include <string> // 包含 string 類別
using namespace std;
int main()
{
    string str; // 宣告一個字串變數
    getline(cin, str, ','); // 從標準輸入讀取字串,直到遇到逗號為止
    cout << str << '\n'; // 輸出字串
    return 0;
}

這樣就可以讀取到逗號前的字串。 而且不需要指定字串的大小,因為 std::string 會自動管理記憶體。

cin.ignore

在使用 getline 之前,如果之前有使用過 cin, 這樣可能會導致 getline 讀取到換行字元, 這是因為 cin 不會讀取空白跟換行字元, 所以如果之前有使用過 cin,那麼緩衝區中可能還有換行字元。 為了解決這個問題,我們可以使用 cin.ignore() 函式來忽略緩衝區中的換行字元:

#include <iostream>
#include <string> // 包含 string 類別
using namespace std;

int main()
{
    int n; // 宣告一個整數變數
    cin >> n; // 從標準輸入讀取整數
    cin.ignore(); // 忽略緩衝區中的換行字元
    for(int i = 0; i < n; ++i)
    {
        string str; // 宣告一個字串變數
        getline(cin, str); // 從標準輸入讀取包含空白的字串
        cout << str << '\n'; // 輸出字串
    }
}

這樣就可以正確地讀取包含空白的字串了。

std::string 提供了許多方便的函式來處理字串,例如:

  • length()size():返回字串的長度
  • empty():檢查字串是否為空
  • clear():清空字串
  • append()+=:連接字串

更多函式可以參考 C++ Reference

不同於 strlenstd::string 的長度可以直接使用 length()size() 函式來取得,而且它是早就記錄好的值,因此不會有一直翻書的問題。

字串與數字的轉換

如果現在你有一個數字字串,想要把它轉換成整數或浮點數, 你字串是 char array 的話,可以使用 atoiatof 函式:

#include <iostream>
#include <cstdlib> // 包含 atoi 和 atof 函式
using namespace std;
int main()
{
    char str[] = "123"; // 數字字串
    int num = atoi(str); // 將字串轉換為整數
    cout << num << '\n'; // 輸出整數
    double dnum = atof(str); // 將字串轉換為浮點數
    cout << dnum << '\n'; // 輸出浮點數
    return 0;
}

如果你使用 std::string,可以使用 stoistod 函式:

#include <iostream>
#include <string> // 包含 string 類別
using namespace std;
int main()
{
    string str = "123"; // 數字字串
    int num = stoi(str); // 將字串轉換為整數
    cout << num << '\n'; // 輸出整數
    double dnum = stod(str); // 將字串轉換為浮點數
    cout << dnum << '\n'; // 輸出浮點數
    return 0;
}

這些函式會自動處理字串中的空白字元和其他非數字字元。

Stringstream

字串流是 C++ 獨有的強大工具, 可以用來在字串和其他資料型態之間進行轉換, 它定義在 <sstream> 標頭檔中。

字串流可以用來讀取和寫入字串,就像使用 cincout 一樣, 但它是針對字串進行操作,而不是標準輸入輸出。

#include <iostream>
#include <sstream> // 包含 stringstream 類別
using namespace std;
int main()
{
    string str = "123 456 789"; // 數字字串
    stringstream ss(str); // 建立字串流
    int num;
    while(ss >> num) // 從字串流讀取整數
        cout << num << '\n'; // 輸出整數
    return 0;
}

上面的程式碼會將字串中的數字分別讀取出來, 並輸出每個數字。

字串流也可以用來將其他資料型態轉換為字串:

#include <iostream>
#include <sstream> // 包含 stringstream 類別
using namespace std;
int main()
{
    int num = 123; // 整數
    stringstream ss; // 建立字串流
    ss << num; // 將整數寫入字串流
    string str = ss.str(); // 從字串流取得字串
    cout << str << '\n'; // 輸出字串
    return 0;
}

stringstream 如果需要清空,需要同時使用 str("")clear()

#include <iostream>
#include <sstream> // 包含 stringstream 類別
using namespace std;
int main()
{
    stringstream ss; // 建立字串流
    ss << "Hello, World!"; // 寫入字串
    cout << ss.str() << '\n'; // 輸出字串
    ss.str(""); // 清空字串流的內容
    ss.clear(); // 清除錯誤狀態
    cout << "After clearing: " << ss.str() << '\n'; // 輸出清空後的字串
    return 0;
}

小結

在這一章中,我們學習了 C++ 中的指標、參考、字元和字串的基本概念, 以及如何使用指標和參考來操作變數的位址。 我們還學習了如何使用字串類別 std::string 來處理字串, 以及如何使用字串流 stringstream 來在字串和其他資料型態之間進行轉換。 這些概念在 C++ 中非常重要,因為它們可以幫助我們更有效地管理記憶體和處理資料。

題單

Reference