C語言探索之旅 | 第二部分第四課:字符串

1240

-- 簡書作者 謝恩銘 轉載請註明出處

第二部分第四課:字符串


上一課C語言探索之旅 | 第二部分第三課:數組,我們結束了關於數組的旅程。

好了,這課我不說“廢話”,直接進入主題(但又好像不是我的風格...)。這一課我們還是會涉及一些指針和數組的知識。

字符串,這是一個編程的術語,用來描述“一段文字”,很簡單。

一個字符串,就是我們可以在內存中以變量的形式儲存的“一段文字”。

比如,用戶名是一個字符串,“程序員聯盟”是一個字符串。

但是我們之前的課說過,呆萌的電腦兄只認得數字,“衆裏尋他千百度,電腦就愛穿秋褲”(不是“穿秋褲”,是“認得數”。)。

所以實際上,電腦是不認得字母的,但是“古靈精怪”的計算機先驅們是如何使電腦可以“識別”字母呢?

接下來我們會看到,他們其實還是很聰明的。

字符類型


在這個小部分,我們把注意力先集中在字符類型上。

如果你還記得,之前的課程中我們說過: 有符號字符類型(char)是用來儲存範圍從-128到127的數的; unsigned char(無符號字符類型)用來儲存範圍從0到255的數。

注意: 雖然char類型可以用來儲存數值,但是在C語言中卻鮮少用char來儲存一個數。
通常,即使我們要表示的數比較小,我們也會用int類型來儲存。
當然了,用int來儲存比用char來儲存在內存上更佔空間,但是今天的電腦基本上是不缺那點內存的,“有內存任性嘛”。

char類型一般用來儲存一個字符,注意,是 一個 字符。

前面的課程也提到了,因爲電腦只認得數字,所以計算機先驅們建立了一個表格(比較常見的有ASCII表, 更完整一些的有Unicode表),用來約定字符和數字之間的轉換關係,例如字母A(大寫)對應的數字是65。

C語言可以很容易地轉換字符和其對應的數值。爲了獲取到某個字符對應的數值(電腦底層其實都是數值),只需要把該字符用單引號括起來,像這樣:

'A'

在編譯的時候,'A'會被替換成實際的數值: 65

我們來測試一下:

#include <stdio.h>

int main(int argc, char *argv[])
{
  char letter = 'A';

  printf("%d\n", letter);

  return 0;
}

程序輸出:

65

所以,我們可以確信大寫字母A的對應數值是65。類似地,大寫字母B對應66, C對應67, 以此類推。

如果我們測試小寫字母,那你會看到a和A的數值是不一樣的,小寫字母a的數值是97。

實際上,在大寫字母和小寫字母之間有一個很簡單的轉換公式,就是

小寫字母的數值 = 大寫字母的數值 + 32

所以電腦是區分大小寫的. 看似呆萌的電腦兄還是可以的麼。

大部分所謂“基礎”的字符都被編碼成0到127之間的數值了。在ASCII表(發音【aski】)的官網 http://www.asciitable.com/ 上,我們可以看到大部分常用的字符的對應數值。

當然這個表我們也可以在其他網站上找到,比如維基百科,百度百科,等等。

顯示字符


要顯示一個字符,最常用的還是printf函數啦。這個函數真的很強大,我們會經常用到。

上面的例子中,我們用%d格式,所以顯示的是字符對應的數值(%d是整型),如果要顯示字符實際的樣子,需要用到%c格式(c是英語character[字符]的首字母):

int main(int argc, char *argv[])
{
  char letter = 'A';

  printf("%c\n", letter);

  return 0;
}

程序輸出:

A

哇,我們知道如何輸出一個字符了,可喜可賀!(小編你也該吃藥了...)

當然我們也可以用常見的scanf函數來請求用戶輸入一個字符,而後用printf函數打印:

int main(int argc, char *argv[])
{
  char letter = 0;

  scanf("%c", &letter);

  printf("%c\n", letter);

  return 0;
}

如果我輸入C,那我將看到:

C
C

第一個字母C是我輸入給scanf函數的,第二個C是printf函數打印的。

以上就是對於字符類型char我們大致需要知道的,請牢記以下幾點:

  • signed char(有符號字符類型)用來儲存範圍從-128到127的數
  • unsigned char(無符號字符類型)用來儲存範圍從0到255的數
  • C語言中,如果你沒寫signed或unsigned關鍵字,默認情況下是signed(有符號)
  • 計算機先驅們給電腦規定了一個表,電腦可以遵照裏面的轉換原則來轉換字符和數值,一般這個表是ASCII表
  • char類型只能儲存一個字符
  • 'A'在編譯時會被替換成實際的數值:65 。因此,我們使用單引號來獲得一個字符的值

字符串其實就是字符的數組


這一部分的內容,就如這個小標題所言。

事實上: 一個字符串就是一個“字符的數組”,僅此而已。

到這裏,你是否對字符串有了更直觀的理解呢?

如果我們創建一個字符數組:

char string[5];

然後我們在數組的第一個成員上儲存‘H’,就是string[0] = 'H',第二個成員上儲存'E'(string[1] = 'H'),第三個成員上儲存'L'(string[2] = 'L'),第四個成員儲存'L' (string[3] = 'L'),第五個成員儲存'O'(string[4] = 'O'),那麼我們就構造了一個字符串啦。

下圖對於字符串在內存中是怎麼存儲的,可以給出一個比較直觀的印象(注意: 實際的情況比這個圖演示的要略微複雜一些,待會兒會解釋):

1240

上圖中,我們可以看到一個數組,擁有5個成員,在內存上連續存放,構成一個字符串 "HELLO"。

對於每一個儲存在內存地址上的字符,我們用了單引號把它括起來,是爲了突出實際上儲存的是數值,而不是字符。

在內存上,儲存的就是此字符對應的數值。

實際上,一個字符串可不是就這樣結束了,上面的圖示其實不完整。

一個字符串必須在最後包含一個特殊的字符,稱爲“字符串結束符”,它是'\0',對應的數值是0。

“爲什麼要在字符串結尾加這麼一個多餘的字符呢?”

問得好!

那是爲了讓電腦知道一個字符串到哪裏結束。

'\0'用於告訴電腦:“停止,字符串到此結束了,不要再讀取了,先退下吧”。

因此,爲了在內存中存儲字符串"HELLO"(5個字符),用5個成員的字符數組是不夠的,需要6個!

因此每次創建字符串時,需要記得在字符數組的結尾留一個字符給'\0'。

忘記字符串結束符是C語言中一個常見的錯誤

因此,下面纔是正確展示我們的字符串"HELLO"在內存中實際存放情況的示意圖:

1240

如上圖所見,這個字符串包含6個字符,而不是5個。

也多虧了這個字符串結束符'\0',我們就無需記得字符串的長度了,因爲它會告訴電腦字符串在哪裏結束。

因此,我們就可以將我們的字符數組作爲參數傳遞給函數,而不需要傳遞字符數組的大小了。

這個好處只針對字符數組,你可以在傳遞給函數時將其寫爲 char *或者char[]類型。

對於其他類型的數組,我們總是要在某處記錄下它的長度。

字符串的創建和初始化


如果我們想要用“Hello”來初始化字符數組string,我們可以用以下的方式來實現。當然,有點沒效率:

char string[6]; // 六個char構成的數組,爲了儲存: H-e-l-l-o + \0

string[0] = 'H';
string[1] = 'e';
string[2] = 'l';
string[3] = 'l';
string[4] = 'o';
string[5] = '\0';

雖然是笨辦法,但至少行得通。

我們用printf函數來測試一下。

要使printf函數能顯示字符串,我們需要用到%s這個符號(s就是英語string(字符串)的首字母):

#include <stdio.h>

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

  char string[6]; // 六個char構成的數組,爲了儲存: H-e-l-l-o + \0

  string[0] = 'H';
  string[1] = 'e';
  string[2] = 'l';
  string[3] = 'l';
  string[4] = 'o';
  string[5] = '\0';

  // 顯示字符串內容
  printf("%s\n", string);

  return 0;
}

程序輸出:

Hello

如果我們的字符串內容多起來,上面的方法就更顯拙劣了。其實啊,初始化字符串還有更簡單的一種方式(小編你好“奸詐”,不早講,害我寫代碼這麼辛苦...):

int main(int argc, char *argv[])
{
  char string[] = "Hello"; // 字符數組的長度會被自動計算

  printf("%s\n", string);

  return 0;
}

以上程序的第一行,我們寫了一個char []類型的變量,其實也可以寫成 char * 同樣是可以運行的:

char *string = "Hello";

這種方法就比之前一個字符一個字符初始化的方法高大上多了,因爲只需要在雙引號裏輸入你想要創建的字符串,C語言的編譯器就很智能地爲你計算好了字符串的大小。

編譯器計算你輸入的字符的數目,然後再加上一個'\0'的長度(是1),就把你的字符串裏的字符一個接一個寫到內存某個地方,在最後加上'\0'這個字符串結束符,就像我們剛纔用第一種方式自己一步步做的。

但是簡便也有缺陷,我們會發現,對於字符數組來說,這種方法只能用於初始化,你在之後的程序中就不能再用這種方式來給整個數組賦值了,比如你不能這樣:

char string[] = "Hello";
string = "nihao"; // --> 出錯!

只能一個字符一個字符地改,例如:

string[0] = 'j';  // --> 可以!

但是問題又來了,對於用char *來聲明的字符串,我們可以在之後整個重新賦值,但是不可以單獨修改某個字符:

char *string = "Hello";
string = "nihao"; // --> 可以!

這樣是可以的,但是如果修改其中的一個字符,就不可以:

string[1] = 'a'; // --> 出錯!

很有意思吧。大家可以親自動手試試。所以這裏就引出了一個話題:

指針和數組根本就是兩碼事!

爲什麼會出現上述的情況呢?

那是因爲:

1.

char stringArray[] = "Hello";

這樣聲明的是一個字符數組,裏面的字符串是儲存在內存的變量區,是在棧上,所以可以修改每個字符的內容,但是不可以通過數組名整體修改:

stringArray = "nihao"; // --> 出錯!

只能一個個單獨改:

stringArray[0] = 'a'; // --> 可以!

因爲之前的課程裏說過,stringArray這個數組的名字表示的是數組首元素的首地址。

2.

char *stringPointer = "Hello";

這樣聲明的是一個指針,stringPointer是指針的名字。指針變量在32 位系統下,永遠佔4 個byte(字節),其值爲某一個內存的地址。

所以stringPointer裏面只是存放了一個地址,這個地址上存放的字符串是常量字符串。這個常量字符串存放在內存的靜態區,不可以更改。

和上面的字符數組情況不一樣,上面的字符數組是本身存放了那一整個字符串。

stringPointer[0] = 'a'; // --> 出錯!

但是可以改變stringPointer指針的指向:

stringPointer = "nihao"; // --> 可以!(因爲可以修改指針指向哪裏)

大家可以自己測試一下:

char *n1 = "it";
char *n2 = "it";

printf("%p\n%p\n", n1, n2); //用%p查看地址

會發現二者的結果是一樣的,指向同一個地址!

再進一步測試(生命在於折騰):

char *n1 = "it";
char *n2 = "it";

printf("%p\n%p\n",n1,n2);

n1 = "haha";

printf("%p\n%p\n",n1,n2);

你會發現以上程序,指針n2所指向的地址一直沒變,而n1在經過

n1 = "haha";

之後,它所指向的地址就改變了。

經過上面地分析,可能很多朋友還是有點暈,特別是可能不太清楚內存各個區域的區別。

如果有興趣深入探究,既可以自己去看相關的C語言書籍。也可以參考下表和一些解釋,如果暫時不想把自己搞得更暈,可以跳過,以後講到相關內容時自然更好理解。

名稱 內容
代碼段 可執行代碼、字符串常量
數據段 已初始化全局變量、已初始化全局靜態變量、局部靜態變量、常量數據
BSS段 未初始化全局變量,未初始化全局靜態變量
局部變量、函數參數
動態內存分配

一般情況下,一個可執行二進制程序(更確切的說,在Linux操作系統下爲一個進程單元)在存儲(沒有調入到內存運行)時擁有3個部分,分別是代碼段、數據段和BSS段。

這3個部分一起組成了該可執行程序的文件。

(1)代碼段(text segment):存放CPU執行的機器指令。通常代碼段是可共享的,這使得需要頻繁被執行的程序只需要在內存中擁有一份拷貝即可。代碼段也通常是隻讀的,這樣可以防止其他程序意外地修改其指令。另外,代碼段還規劃了局部數據所申請的內存空間信息。
代碼段(code segment/text segment)通常是指用來存放程序執行代碼的一塊內存區域。這部分區域的大小在程序運行前就已經確定,並且內存區域通常屬於只讀,某些架構也允許代碼段爲可寫,即允許修改程序。在代碼段中,也有可能包含一些只讀的常數變量,例如字符串常量等。

(2)數據段(data segment):或稱全局初始化數據段/靜態數據段(initialized data segment/data segment)。該段包含了在程序中明確被初始化的全局變量、靜態變量(包括全局靜態變量和局部靜態變量)和常量數據。

(3)未初始化數據段:亦稱BSS(Block Started by Symbol)。該段存入的是全局未初始化變量、靜態未初始化變量。

而當程序被加載到內存單元時,則需要另外兩個域:棧域和堆域。

(4)棧(stack):存放函數的參數值、局部變量的值,以及在進行任務切換時存放當前任務的上下文內容。

(5)堆(heap):用於動態內存分配(之後的課程馬上會講到),即使用malloc/free系列函數來管理的內存空間。

在將應用程序加載到內存空間執行時,操作系統負責代碼段、數據段和BSS段的加載,並將在內存中爲這些段分配空間。

棧也由操作系統分配和管理,而不需要程序員顯式地管理;堆由程序員自己管理,即顯式地申請和釋放空間。

很多C語言地初學者搞不懂指針和數組到底有什麼樣的關係。

現在就告訴大家:指針和數組之間沒有任何關係!它們是“清白”的...

  • 指針就是指針:指針變量在32 位系統下,永遠佔4 個byte(字節),其值爲某一個內存的地址。指針可以指向任何地方,但不是任何地方你都能通過這個指針變量訪問到。

  • 數組就是數組:其大小與元素的類型和個數有關。定義數組時必須指定其元素的類型和個數。數組可以存任何類型的數據,但不能存函數。

推薦大家去看《C語言深度解剖》這本只有100多頁的PDF,是國人寫的,裏面對於指針和數組分析得很全面。

不禁感嘆,C語言果然是博(xiang)大(dang)精(keng)深(die)。

從scanf函數取得一個字符串


我們可以用scanf函數獲取用戶輸入的一個字符串,也要用到%s符號。

但是有一個問題:就是你不能知道用戶究竟會輸入多少字符。

假如我們的程序是問用戶他的名字是什麼。那麼他可能回答Tom,只有三個字符,或者Bruce LI,就有8個字符了。

所以我們只能用一個足夠大的數組來存儲名字,例如 char [100]。你會說這樣太浪費內存了,但是前面我們也說過了,目前的電腦一般不在乎這點內存。

所以我們的程序會是這樣:

int main(int argc, char *argv[])
{
  char name[100];

  printf("請問您叫什麼名字 ? ");

  scanf("%s", name);

  printf("您好, %s, 很高興認識您!\n", name);

  return 0;
}

運行程序:

請問您叫什麼名字?Oscar
您好,Oscar,很高興認識您!

操縱字符串的一些常用函數


字符串在C語言裏是很常用的。事實上,此刻你在電腦或手機屏幕上看到的這些單詞、句子等,都是在電腦內存裏的字符數組。

爲了方便我們操縱字符串,C語言的設計者們在 string 這個標準庫中已經寫好了很多函數,可供我們使用。

當然在這以前,需要在你的.c源文件中引入這個頭文件:

#include <string.h>

下面我們就來介紹它們之中最常用的一些吧:

strlen:計算字符串的長度


strlen函數返回一個字符串的長度(不包括\0)。

爲什麼名字是strlen?其實很好記:

  • str是string(英語“字符串”)的首三個字母
  • len是length(英語“長度”)的首三個字母

因此,strlen就是“字符串長度”。

函數原型是這樣:

size_t strlen(const char* string);

注意:size_t是一個特殊的類型,它意味着函數返回一個對應大小的數目。
不是像int,char,long,double之類的基本類型,而是一個被“創造”出來的類型。
在接下來的課程中我們就會學到如何創建自己的變量類型。
暫時說來,我們先滿足於將strlen函數的返回值存到一個int變量裏(電腦會把size_t自動轉換成int)。當然,嚴格來說應該用size_t類型,但是我們這裏暫時不深究了。

函數的參數是 const char *類型,之前的課程中我們學過,const(只讀的變量)表明此類型的變量是不能被改變的,所以函數strlen並不會改變它的參數的值。

寫個程序測試一下strlen函數:

#include <string.h>
#include <stdio.h>

int main(int argc, char *argv[])
{
  char string[] = "Hello";
  int stringLength = 0;

  // 將字符串的長度儲存到stringLength中
  stringLength = strlen(string);

  printf("字符串%s中有%d個字符\n", string, stringLength);

  return 0;
}

程序運行,顯示:

字符串Hello中有5個字符

當然了,這個strlen函數,其實我們自己也可以很容易地實現。只需要用一個循環,從開始一直讀入字符串中的字符,計算數目,一直讀到'\0'字符結束循環。

我們就來實現我們自己的strlen函數好了:

#include <string.h>
#include <stdio.h>

int stringLength(const char *string);

int main(int argc, char *argv[])
{
  char string[] = "Hello";
  int length = 0;

  length = stringLength(string);

  printf("字符串%s中有%d個字符\n", string, length);

  return 0;
}

int stringLength(const char *string)
{
  int charNumber = 0;
  char currentChar = 0;

  do
  {
    currentChar = string[charNumber];
    charNumber++;
  } while(currentChar != '\0'); // 我們做循環,直到遇到'\0',跳出循環

  charNumber--; // 我們將charNumber減一,使其不包含'\0'的長度

  return charNumber;
}

程序輸出:

字符串Hello中有5個字符

strcpy:把一個字符串的內容複製到另一個字符串裏


爲什麼名字是strcpy?其實很好記:

  • str是string(英語“字符串”)的首三個字母
  • cpy是copy(英語“拷貝,複製”)的縮寫

因此,strcpy就是“字符串拷貝”。

函數原型:

char* strcpy(char* targetString, const char* stringToCopy);

這個函數有兩個參數:

  • targetString:這是一個指向字符數組的指針,我們要複製字符串到這個字符數組裏。

  • stringToCopy:這是一個指向要被複制的字符串的指針。

函數返回一個指向targetString的指針,通常我們不需要獲取這個返回值。

用以下程序測試此函數:

#include <string.h>
#include <stdio.h>

int main(int argc, char *argv[])
{
  /* 我們創建了一個字符數組string,裏面包含了幾個字符。
  我們又創建了另一個字符數組copy,包含100個字符,爲了足夠容納拷貝過來的字符 */
  char string[] = "Hello", copy[100] = {0};

  strcpy(copy, string); // 我們把string複製到copy中

  // 如果一切順利,copy的值應該和string是一樣的
  printf("string 是 %s\n", string);
  printf("copy 是 %s\n", copy);

  return 0;
}

程序輸出:

string 是 Hello
copy 是 Hello

如果我們的copy數組的長度小於6,那麼程序會出錯,因爲string的總長度是6(最後有一個'\0'字符串結束符)。

strcpy的原理圖解如下:

1240

strcat:連接兩個字符串


爲什麼名字是strcat?其實很好記:

  • str是string(英語“字符串”)的首三個字母
  • cat是concatenate (英語“連結,使連鎖”)的縮寫

因此,strcat就是“字符串連結”。

strcat函數的作用是連接兩個字符串,就是把一個字符串接到另一個的結尾。

函數原型:

char* strcat(char* string1, const char* string2);

因爲string2是const類型,所以我們就想到了,這個函數肯定是將string2的內容接到string1的結尾,改變了string1所指向的字符指針,然後返回指向string1所指字符數組的指針。

略微有點拗口,但不難理解吧。

寫個程序測試一下:

#include <string.h>
#include <stdio.h>

int main(int argc, char *argv[])
{
  /* 我們創建了兩個字符串,字符數組string1需要足夠長,因爲我們要將string2的內容接到其後 */
  char string1[100] = "Hello ", string2[] = "Oscar!";

  strcat(string1, string2); // 將string2接到string1後面

  // 如果一切順利,那麼string1的值應該會變爲"Hello Oscar!"
  printf("string1 是 %s\n", string1);

  // string2沒有變
  printf("string2 始終是 %s\n", string2);

  return 0;
}

程序輸出:

string1 是 Hello Oscar!
string2 始終是 Oscar!

函數的原理如下:

1240

當strcat函數將string2連接到string1的尾部時,它需要先刪去string1字符串最後的'\0'。

strcmp:比較兩個字符串


爲什麼名字是strcmp?其實很好記:

  • str是string(英語“字符串”)的首三個字母
  • cmp是compare(英語“比較”)的縮寫

因此,strcmp就是“字符串比較”。

函數原型:

int strcmp(const char* string1, const char* string2);

可以看到,strcmp函數不能改變參數string1和string2,因爲它們都是const類型。

這次,函數的返回值有用了。strcmp返回:

  • 0:當兩個字符串相等時

  • 非零的整數(負數或正數):當兩個字符串不等時

用以下程序測試strcmp函數:

#include <string.h>
#include <stdio.h>

int main(int argc, char *argv[])
{
  char string1[] = "Text of test", string2[] = "Text of test";

  if (strcmp(string1, string2) == 0) // 如果兩個字符串相等
  {
    printf("兩個字符串相等\n");
  }
  else
  {
    printf("兩個字符串不相等\n");
  }

  return 0;
}

程序輸出:

兩個字符串相等

sprintf:向一個字符串寫入


當然,這個函數其實不是在string.h這個頭文件裏,而是在stdio.h頭文件裏。但是它也與字符串的操作有關,所以我們也介紹一下,而且這個函數是很常用的。

看到sprintf函數的名字,大家是否想到了printf函數呢?

printf函數是向標準輸出(一般是屏幕)寫入東西,而sprintf是向一個字符串寫入東西。最前面的s就是英語string(“字符串”的意思)的首字母。

寫個程序測試一下此函數:

#include <stdio.h>

int main(int argc, char *argv[])
{
  char string[100];
  int age = 18;

  // 我們向string裏寫入"你18歲了"
  sprintf(string, "你%d歲了", age);

  printf("%s\n", string);

  return 0;
}

程序輸出:

你18歲了

其他常用的還有一些函數,如 strstr(在字符串中查找一個子串),strchr(在字符串裏查找一個字符),等等,我們就不一一介紹了。

總結


  1. 電腦不認識字符,它只認識數字(0和1)。爲了解決這個問題,計算機先驅們用一個表格規定了字符與數值的對應關係,最常用的是ASCII表和Unicode表。

  2. 字符類型char用來存儲一個字符,且只能存儲一個字符。實際上存儲的是一個數值,但是電腦會在顯示時將其轉換成對應的字符。

  3. 爲了創建一個詞或一句話,我們需要構建一個字符串,我們用字符數組來實現。

  4. 所有的字符串都是以'\0'結尾,這個特殊的字符'\0'標誌着字符串的結束。

  5. 在string這個C語言標準庫中,有很多操縱字符串的函數,只需要引入頭文件 string.h即可。

第二部分第五課預告:

今天的課就到這裏,一起加油咯。

下一次我們學習第二部分第五課:C語言探索之旅 | 第二部分第五課:預處理

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章