資料結構和演算法面試題系列—C指標、陣列和結構體

ssjhust發表於2018-09-01

這個系列是我多年前找工作時對資料結構和演算法總結,其中有基礎部分,也有各大公司的經典的面試題,最早釋出在CSDN。現整理為一個系列給需要的朋友參考,如有錯誤,歡迎指正。本系列完整程式碼地址在 這裡

0 概述

在用C語言實現一些常見的資料結構和演算法時,C語言的基礎不能少,特別是指標和結構體等知識。

1 關於ELF檔案

linux中的C編譯得到的目標檔案和可執行檔案都是ELF格式的,可執行檔案中以segment來劃分,目標檔案中,我們是以section劃分。一個segment包含一個或多個section,通過readelf命令可以看到完整的section和segment資訊。看一個栗子:

char pear[40];
static double peach;
int mango = 13;
char *str = "hello";

static long melon = 2001;

int main()
{
    int i = 3, j;
    pear[5] = i;
    peach = 2.0 * mango;
    return 0;
}
複製程式碼

這是個簡單的C語言程式碼,現在分析下各個變數儲存的位置。其中mango,melon屬於data section,pear和peach屬於common section中,而且peach和melon加了static,說明只能本檔案使用。而str對應的字串"helloworld"儲存在rodata section中。main函式歸屬於text section,函式中的區域性變數i,j在執行時在棧中分配空間。注意到前面說的全域性未初始化變數peach和pear是在common section中,這是為了強弱符號而設定的。那其實最終連結成為可執行檔案後,會歸於BSS segment。同樣的,text section和rodata section在可執行檔案中都屬於同一個segment。

更多ELF內容參見《程式猿的自我修養》一書。

2 指標

想當年學習C語言最怕的就是指標了,當然《c與指標》和《c專家程式設計》以及《高質量C程式設計》裡面對指標都有很好的講解,系統回顧還是看書吧,這裡我總結了一些基礎和易錯的點。環境是ubuntu14.10的32位系統,編譯工具GCC。

2.1 指標易錯點

/***
指標易錯示例1 demo1.c
***/

int main()
{
    char *str = "helloworld"; //[1]
    str[1] = 'M'; //[2] 會報錯
    char arr[] = "hello"; //[3]
    arr[1] = 'M';
    return 0;
}
複製程式碼

demo1.c中,我們定義了一個指標和陣列分別指向了一個字串,然後修改字串中某個字元的值。編譯後執行會發現[2]處會報錯,這是為什麼呢?用命令gcc -S demo1.c 生成彙編程式碼就會發現[1]處的helloworld是儲存在rodata section的,是隻讀的,而[3]處的是儲存在棧中的。所以[2]報錯而[3]正常。在C中,用[1]中的方式建立字串常量並賦值給指標,則字串常量儲存在rodata section。而如果是賦值給陣列,則儲存在棧中或者data section中(如[3]就是儲存在棧中)。示例2給出了更多容易出錯的點,可以看看。

/***
指標易錯示例2 demo2.c
***/
char *GetMemory(int num) {
    char *p = (char *)malloc(sizeof(char) * num);
    return p;
}

char *GetMemory2(char *p) {
    p = (char *)malloc(sizeof(char) * 100);
}

char *GetString(){
    char *string = "helloworld";
    return string;
}

char *GetString2(){
    char string[] = "helloworld";
    return string;
}

void ParamArray(char a[])
{
    printf("sizeof(a)=%d\n", sizeof(a)); // sizeof(a)=4,引數以指標方式傳遞
}

int main()
{
    int a[] = {1, 2, 3, 4};
    int *b = a + 1;
    printf("delta=%d\n", b-a); // delta=4,注意int陣列步長為4
    printf("sizeof(a)=%d, sizeof(b)=%d\n", sizeof(a), sizeof(b)); //sizeof(a)=16, sizeof(b)=4
    ParamArray(a); 
        
        
    //引用了不屬於程式地址空間的地址,導致段錯誤
    /*
    int *p = 0;
    *p = 17;         
    */
        
    char *str = NULL;
    str = GetMemory(100);
    strcpy(str, "hello");
    free(str); //釋放記憶體
    str = NULL; //避免野指標

	//錯誤版本,這是因為函式引數傳遞的是副本。
	/*
    char *str2 = NULL;
    GetMemory2(str2);
    strcpy(str2, "hello");
    */

    char *str3 = GetString();
    printf("%s\n", str3);

    //錯誤版本,返回了棧指標,編譯器會有警告。
    /*
    char *str4 = GetString2();
    */
    return 0;
}
複製程式碼

2.2 指標和陣列

在2.1中也提到了部分指標和陣列內容,在C中指標和陣列在某些情況下可以相互轉換來使用,比如char *str="helloworld"可以通過str[1]來訪問第二個字元,也可以通過*(str+1)來訪問。 此外,在函式引數中,使用陣列和指標也是等同的。但是指標和陣列在有些地方並不等同,需要特別注意。

比如我定義一個陣列char a[9] = "abcdefgh";(注意字串後面自動補\0),那麼用a[1]讀取字元'b'的流程是這樣的:

  • 首先,陣列a有個地址,我們假設是9980。
  • 然後取偏移值,偏移值為索引值*元素大小,這裡索引是1,char大小也為1,因此加上9980為9981,得到陣列a第1個元素的地址。(如果是int型別陣列,那麼這裡偏移就是1 * 4 = 4)
  • 取地址9981處的值,就是'b'。

那如果定義一個指標char *a = "abcdefgh";,我們通過a[1]來取第一個元素的值。跟陣列流程不同的是:

  • 首先,指標a自己有個地址,假設是4541.
  • 然後,從4541取a的值,也就是字串“abcdefgh”的地址,假定是5081。
  • 接著就是跟之前一樣的步驟了,5081加上偏移1,取5082地址處的值,這裡就是'b'了。

通過上面的說明可以發現,指標比陣列多了一個步驟,雖然看起來結果是一致的。因此,下面這個錯誤就比較好理解了。在demo3.c中定義了一個陣列,然後在demo4.c中通過指標來宣告並引用它,顯然是會報錯的。如果改成extern char p[];就正確了(當然宣告你也可以寫成extern char p[3],宣告裡面的陣列大小跟實際大小不一致是沒有關係的),一定要保證定義和宣告匹配。

/***
demo3.c
***/
char p[] = "helloworld";

/***
demo4.c
***/
extern char *p;
int main()
{
    printf("%c\n", p[1]);
    return 0;
}
複製程式碼

3 typedef和#define

typedef和#define都是經常用的,但是它們是不一樣的。一個typedef可以塞入多個宣告器,而#define一般只能有一個定義。在連續宣告中,typedef定義的型別可以保證宣告的變數都是同一種型別,而#define不行。此外,typedef是一種徹底的封裝型別,在宣告之後不能再新增其他的型別。如程式碼中所示。

#define int_ptr int *
int_ptr i, j; //i是int *型別,而j是int型別。

typedef char * char_ptr;
char_ptr c1, c2; //c1, c2都是char *型別。

#define peach int
unsigned peach i; //正確

typdef int banana;
unsigned banana j; //錯誤,typedef宣告的型別不能擴充套件其他型別。
複製程式碼

另外,typedef在結構體定義中也很常見,比如下面程式碼中的定義。需要注意的是,[1]和[2]是很不同的。當你如[1]中那樣用typedef定義了struct foo,那麼其實除了本身的foo結構標籤,你還定義了foo這種結構型別,所以可以直接用foo來宣告變數。而如[2]中的定義是不能用bar來宣告變數的,因為它只是一個結構變數,並不是結構型別。

還有一點需要說明的是,結構體是有自己名字空間的,所以結構體中的欄位可以跟結構體名字相同,比如[3]中那樣也是合法的,當然儘量不要這樣用。後面一節還會更詳細探討結構體,因為在Python原始碼中也有用到很多結構體。

typedef struct foo {int i;} foo; //[1]
struct bar {int i;} bar; //[2]

struct foo f; //正確,使用結構標籤foo
foo f; //正確,使用結構型別foo

struct bar b; //正確,使用結構標籤bar
bar b; // 錯誤,使用了結構變數bar,bar已經是個結構體變數了,可以直接初始化,比如bar.i = 4;

struct foobar {int foorbar;}; //[3]合法的定義
複製程式碼

4 結構體

在學習資料結構的時候,定義連結串列和樹結構會經常用到結構體。比如下面這個:

struct node {
    int data;
    struct node* next;
};
複製程式碼

在定義連結串列的時候可能就有點奇怪了,為什麼可以這樣定義,貌似這個時候struct node還沒有定義好為什麼就可以用next指標指向用這個結構體定義了呢?

4.1 不完全型別

這裡要說下C語言裡面的不完全型別。C語言可以分為函式型別,物件型別以及不完全型別。而物件型別還可以分為標量型別和非標量型別。算術型別(如int,float,char等)和指標型別屬於標量型別,而定義完整的結構體,聯合體,陣列等都是非標量型別。而不完全型別是指沒有定義完整的型別,比如下面這樣的

struct s;
union u;
char str[];
複製程式碼

具有不完全型別的變數可以通過多次宣告組合成一個完全型別。比如下面2詞宣告str陣列是合法的:

char str[];
char str[10];
複製程式碼

此外,如果兩個原始檔定義了同一個變數,只要它們不全部是強型別的,那麼也是可以編譯通過的。比如下面這樣是合法的,但是如果將file1.c中的int i;改成強定義如int i = 5;那麼就會出錯了。

//file1.c
int i;

//file2.c
int i = 4;
複製程式碼

4.2 不完全型別結構體

不完全型別的結構體十分重要,比如我們最開始提到的struct node的定義,編譯器從前往後處理,發現struct node *next時,認為struct node是一個不完全型別,next是一個指向不完全型別的指標,儘管如此,指標本身是完全型別,因為不管什麼指標在32位系統都是佔用4個位元組。而到後面定義結束,struct node成了一個完全型別,從而next就是一個指向完全型別的指標了。

4.3 結構體初始化和大小

結構體初始化比較簡單,需要注意的是結構體中包含有指標的時候,如果要進行字串拷貝之類的操作,對指標需要額外分配記憶體空間。如下面定義了一個結構體student的變數stu和指向結構體的指標pstu,雖然stu定義的時候已經隱式分配了結構體記憶體,但是你要拷貝字串到它指向的記憶體的話,需要顯示分配記憶體。

struct student {
    char *name;
    int age;
} stu, *pstu;

int main()
{
    stu.age = 13; //正確
    // strcpy(stu.name,"hello"); //錯誤,name還沒有分配記憶體空間
        
    stu.name = (char *)malloc(6);
    strcpy(stu.name, "hello"); //正確
        
    return 0;
}
複製程式碼

結構體大小涉及一個對齊的問題,對齊規則為:

  • 結構體變數首地址為最寬成員長度(如果有#pragma pack(n),則取最寬成員長度和n的較小值,預設pragma的n=8)的整數倍
  • 結構體大小為最寬成員長度的整數倍
  • 結構體每個成員相對結構體首地址的偏移量都是每個成員本身大小(如果有pragma pack(n),則是n與成員大小的較小值)的整數倍 因此,下面結構體S1和S2雖然內容一樣,但是欄位順序不同,大小也不同,sizeof(S1) = 8, 而sizeof(S2) = 12. 如果定義了#pragma pack(2),則sizeof(S1)=8;sizeof(S2)=8
typedef struct node1
{
    int a;
    char b;
    short c;
}S1;

typedef struct node2
{
    char b;
    int a;
    short c;
}S2;
複製程式碼

4.4 柔性陣列

柔性陣列是指結構體的最後面一個成員可以是一個大小未知的陣列,這樣可以在結構體中存放變長的字串。如程式碼中所示。**注意,柔性陣列必須是結構體最後一個成員,柔性陣列不佔用結構體大小.**當然,你也可以將陣列寫成char str[0],含義相同。

注:在學習Python原始碼過程中,發現其柔性陣列宣告並不是用一個空陣列或者char str[0],而是用的char str[1],即陣列大小為1。這是因為ISO C標準不允許宣告大小為0的陣列(gcc -pedanti引數可以檢查是否符合ISO C標準),為了可移植性,所以常常看到的是宣告陣列大小為1。當然,很多編譯器比如GCC等把陣列大小為0作為了一個非標準的擴充套件,所以宣告空的或者大小為0的柔性陣列在GCC中是可以正常編譯的。

struct flexarray {
    int len;
    char str[];
} *pfarr;

int main()
{
    char s1[] = "hello, world";
    pfarr = malloc(sizeof(struct flexarray) + strlen(s1) + 1);
    pfarr->len = strlen(s1);
    strcpy(pfarr->str, s1);
    printf("%d\n", sizeof(struct flexarray)); // 4
    printf("%d\n", pfarr->len); // 12
    printf("%s\n", pfarr->str); // hello, world
    return 0;
}
複製程式碼

5 總結

  • 關於const,c語言中的const不是常量,所以不能用const變數來定義陣列,如const int N = 3; int a[N];這是錯誤的。
  • 注意記憶體分配和釋放,杜絕野指標。
  • C語言中弱符號和強符號一起連結是合法的。
  • 注意指標和陣列的區別。
  • typedef和#define是不同的。
  • 注意包含指標的結構體的初始化和柔性陣列的使用。

參考資料

相關文章