C語言中的複雜資料型別,你掌握了哪些?

jaj2003發表於2020-12-26

目錄

複雜資料型別

對於任何一門能夠處理實際應用的程式語言,基本資料型別都是不夠用的,只能存放線性資料的陣列也是不夠用的,例如一個學生擁有姓名、性別、年齡等內容,這樣的內容必須用複雜資料型別來描述,對於高階語言來說複雜資料型別可以用類描述,對於C這樣的程式導向語言,C標準提供了結構體、共用體這樣的複雜資料型別來描述。

結構體

結構體這種資料型別有很多稱呼,有的稱關聯陣列,有的稱為構造型陣列,在結構體中可以定義成員名稱並設定值,有的書本稱為值對,把成員名稱為“鍵”,成員值稱為“鍵值”,一個結構體的定義格式如下:
struct 型別名稱
{
屬性1;
屬性2;

};
注意結構體最後有一個分號,定義了結構體後就可以使用結構體型別定義變數了,例如:

#include<stdio.h>

struct Student
{
	char name[20];
	int age;
	int sex;
};

int main(int argc, char* argv[])
{
	struct Student s1={"xiaoming",5,0};
	printf("s1:%s,%d,%d",s1.name, s1.age, s1.sex);
	return 0;
}

定義結構體和用結構體宣告變數時都必須書寫關鍵字struct,結構體型別可以在函式外宣告也可以在函式內宣告,在函式內宣告的結構體型別外部不能識別,因此沒有通用性,大部分情況下我們都會在檔案頂部宣告結構體。如果想節省程式碼,可以在定義時同時宣告變數,例如:

struct Student
{
	char name[20];
	int age;
	int sex;
} s1,s2;
甚至可以沒有名稱,例如:
struct
{
	char name[20];
	int age;
	int sex;
} s1,s2;

這種匿名方式不能在其它地方定義變數,但可以使用這種方式定義別名,關於別名我們在後面的章節中講解。定義結構體時除了不要忘記最後的分號還要記住不能在結構體內部為成員指定初始值,初始值只能在定義變數時指定,比如這個例子中的:
struct Student s1={“xiaoming”,5,0};
當然我們可以選擇定義變數後再通過s1.name=”xiaoming”初始化,顯然這種方式比較麻煩,適合使用鍵盤輸入或用程式碼讀入資料的場景,為什麼不能在結構體中初始化變數呢?因為結構體是一個原始資料型別,它不像高階語言的類有建構函式,因此不能在定義時進行初始化。

結構體指標和成員的引用

在講結構體指標之前我們必須明確一個概念,那就是C標準對結構體的設定,初學者很容易認為結構體就是高階語言中的類别範本,或者跟陣列一樣是一個資料集合,也容易認為結構體變數名是第一個成員的地址或者是資料集合入口地址,這樣就犯了原始性的概念錯誤,產生這樣的錯覺一是因為結構體擁有成員,很容易將它看作資料集合或類别範本,二是被關聯陣列、構造型陣列這樣的名稱迷惑了,實際上C標準並不將結構體看作為資料集合,更不會引入物件導向的概念將它當作類别範本,而是將結構體定義為一個原始的複雜資料型別,在很多方面處理結構體的方式和基本資料型別一樣,如下:

  1. 結構體名稱僅代表資料型別
    假如我們用struct Student宣告一個結構體變數s1,s1的型別為struct Student,這個型別如同int,float,僅用於解釋s1所佔的記憶體區域的資料型別

  2. 除非使用const,否則用結構體型別宣告的是變數而不是常量,這個變數可以被賦值,且賦值時也是按值傳遞的,我們用下面的程式碼來驗證一下:

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

struct Student
{
	char name[20];
	int age;
	int sex;
};

int main(int argc, char* argv[])
{
	struct Student s1={"xiaoming",5,0};
	struct Student s2=s1;
	
	strcpy(s2.name,"xiaohong");
	s2.age=4;
	s2.sex=1;
	printf("s1:%s,%d,%d\n",s1.name, s1.age, s1.sex);
	printf("s1:%s,%d,%d",s2.name, s2.age, s2.sex);
	return 0;
}

從結果可以看出,修改s2後s1並未發生變化,同樣將結構體變數作為函式引數時也是按值傳遞的,修改函式引數並不會對原來的變數內容產生影響,如果覺得克隆結構體花費的代價較大,可以將&s1傳入函式。

  1. 結構體名稱和變數名都不表示地址
    有人會想,基本資料型別變數名錶示某記憶體的值,陣列名錶示首元素的地址,那麼結構體名和結構體變數名錶示什麼呢?和基本資料型別一樣,結構體名和int,float一樣時資料型別,變數名則是某段記憶體的值,它們都不表示地址,要說區別,那就是基本資料型別變數和陣列名都可以通過printf()輸出,而結構體變數卻不能輸出,因為printf()有支援基本資料型別和指標的格式符,但不提供輸出結構體的格式符,如果用printf(“%d”, s1)輸出結構體變數名會得到什麼結果呢?printf()會將結構體所在記憶體的前幾個位元組當作整數輸出,而且還會給出型別不匹配的警告,因此用printf()輸出結構體本身是沒有意義的。在C語言中,結構體變數只能用於賦值、取址,用sizeof()求大小,或者通過它訪問其成員,嘗試把結構體變數轉化為整數、字串、地址等操作均會失敗,而結構體名如同int, float一樣只能用於型別判斷或用sizeof()計算大小。

瞭解結構體的概念後我們來看看結構體指標,當我們宣告一個結構體指標時,指標型別為結構體型別加上*號,其值是用&號對結構體變數取址,例如:
struct Student *p=&s1;
這和基本資料型別的宣告方式是一樣的,對於存放結構體的記憶體來說,理論上各成員在記憶體中應該是連續存放的,但在編譯器的具體實現中成員之間可能存在縫隙,因此結構體所佔空間大小不一定是各個成員大小的和,它的實際大小應該用sizeof()求出,也因為這個原因,我們不能通過指標位移來獲取成員值。但是即便成員之間有間隙,結構體所在記憶體的起始位置仍然存放第一個成員的內容,因此結構體變數的地址和第一個成員的地址相同,但是型別不一樣,在上面這個例子中,第一個成員為字串,對於字串來說,陣列名的地址、陣列名和第一個字元的地址也是相同的,不同的也是資料型別,因此對於上面的例子,&s1, p, &p.name, p.name以及&p.name[0]的地址都是相同的,用一段程式碼進行測試:

#include<stdio.h>
#include<stdlib.h>

struct Student
{
	char name[20];
	int age;
	int sex;
};

int main(int argc, char* argv[])
{
	struct Student s1={"xiaoming",5,0};
	struct Student *p=&s1;

	printf("&s1=%p,p=%p,&p.name=%p,p.name=%p,&p.name[0]=%p\n",&s1,p,&s1.name,s1.name,&s1.name[0]);
	printf("s1=%d",s1); //直接輸出s1看看結果時什麼
	return 0;
}

上面程式碼輸出結果驗證了我們的猜測,但如果只輸出結構體變數名,結果卻不同,且沒有意義。定義了結構體變數後,就可以通過成員名進行訪問了,例如s1.name, s1.age,前面說過由於結構體成員之間有記憶體間隙,因此不能通過指標位移來訪問成員的地址,要使用結構體指標直接訪問結構體成員,C標準提供了專門的訪問方法,那就是用符號“->”來訪問,例如p->name, p->age,這比使用(*p).name,(*p).age間接訪問看上去要簡潔,很多編輯器使用.和->引用結構體變數的成員時都會提示成員列表。

講到這裡可以看到結構體無論從概念設計、記憶體儲存方式以及訪問方式上都和陣列大相徑庭,它們之間沒有任何相通之處,將陣列的任何用法套用在結構體上都是不適用的。對於結構體的成員來說,成員可以是基本資料型別、陣列或者另外一個結構體,如果成員為陣列,陣列名也是常量,這裡 s1.name和name都是常量,不能通過s1.name=”xxx”來嘗試修改學生的名稱。

結構體陣列

一個陣列的元素可以是基本資料型別或地址,現在有了結構體,陣列元素也可以是結構體型別,定義結構體陣列和定義普通陣列沒有多大區別,但初始化時你會發現陣列和結構體都使用{}進行初始化,因此初始化一個結構體陣列可以寫成{{},{}…}的形式,為了簡化輸入,可以省略結構體的{},下面使用程式碼來演示這兩種初始化的形式:

#include<stdio.h>
#include<stdlib.h>

struct Student
{
	char name[20];
	int age;
	int sex;
};

int main(int argc, char* argv[])
{
	struct Student students1[2]= {{"xiaoming",5,0},{"xiaohong",6,1}};
	struct Student students2[2]= {"xiaoming",5,0,"xiaohong",6,1};
	printf("%s\n",students1[0].name);
	printf("%s",students2[0].name);
	return 0;
}

這種簡化的書寫方式類似用一維陣列初始化二維陣列,但要明白陣列中存放的是什麼,內容不能有任何偏差,陣列元素可以用p++或p++進行遍歷,結合結構體的專用訪問方式,使用p->name訪問結構體成員比(*p).name更簡潔,例如:

#include<stdio.h>
#include<stdlib.h>

struct Student
{
	char name[20];
	int age;
	int sex;
};

int main(int argc, char* argv[])
{
	struct Student students[2]= {{"xiaoming",5,0},{"xiaohong",6,1}};
	struct Student *p=students;
	
	while(p<students+2) puts(p++->name);
	return 0;
}

構建連結串列

從資料結構上看,現在我們有了描述線性結構的陣列和描述雜湊結構的結構體,但對於描述更復雜的資料型別仍然顯得力不從心,例如環形結構、樹形結構等,另外陣列和結構體這樣的原生資料型別一旦定義後其長度不能更改,而實際應用需要的資料結構複雜多變,長度隨時可以變化,為了實現這些需求,我們需要基於已有的原生結構建立更加複雜的資料結構,結構體在其中扮演著關鍵角色。在複雜的資料結構中,每個元素被看作一個節點,節點跟節點之間的關係稱為關係鏈,我們用結構體描述節點,用結構體中包含的指標描述節點之間的關係。單連結串列和雙連結串列是最簡單的資料結構,對於單連結串列,讓節點末尾的指標指向另一個節點的地址從而將多個節點串聯起來;雙連結串列是基於單連結串列的延申,一個節點包含兩個指標,頭部指標指向前一個節點,尾部指標指向後一個節點,從而可以雙向遍歷節點。連結串列雖然也是線性結構,但比陣列在結構方面有優勢,它的長度可以隨時修改,從中間新增刪除某個節點也不會導致其它元素整體移動,缺點是節點在記憶體中的存放不是連續的,結構越複雜記憶體越零碎,下例建立一個包含10個元素的單連結串列:

#include<stdio.h>
#include<stdlib.h>

struct Node
{
	int num;
	struct Node *next;
};

const int SIZE=sizeof(struct Node); //求出自定義型別佔用記憶體大小

int main(int argc, char* argv[])
{
	int len=10; //元素個數
	int i=0; //元素索引
	struct Node *head,*p,*next; //建立單連結串列下需要的各種指標
	
	p=head=malloc(SIZE);//建立頭元素
	p->num=i++;//初始化頭元素
	p->next=NULL;
	//建立其它元素
	while(i<len)
	{
		//建立並初始化下一個節點
		next=malloc(SIZE);
		next->num=i;
		next->next=NULL;
		
		p->next=next;//將上一個節點連結到下一個節點
		p=next;//跳轉到下一個節點
		i++;
	}
	//輸出連結串列
	p=head;
	do
	{
		printf("node%d\n",p->num);
		p=p->next;
	} while(p!=NULL);
}

這裡將Node型別的長度定義為常數SIZE以便後面申請動態空間使用,先建立頭元素head然後再建立其餘的元素,雖然程式碼可以繼續簡化,例如可以不宣告索引i,把建立頭元素的程式碼都寫入while迴圈中,但對於複雜的邏輯我們不求程式碼最精簡,而是讓程式碼閱讀起來更清晰,這樣便於除錯。能夠建立單連結串列就可以建立其它資料結構型別,但是由於缺乏類,方法不能封裝到資料型別中,只能寫成全域性函式,我們來為Node新增一個方法test():

#include<stdio.h>
#include<stdlib.h>
struct Node
{
	int num;
	struct Node *next;
};

void testNode(struct Node *n)
{
	printf("node%d\n",n->num);
}

const int SIZE=sizeof(struct Node); //求出自定義型別佔用記憶體大小

int main(int argc, char* argv[])
{
	int len=10; //元素個數
	int i=0; //元素索引
	struct Node *head,*p,*next; //建立單連結串列下需要的各種指標

	p=head=malloc(SIZE);//建立頭元素
	p->num=i++;//初始化頭元素
	p->next=NULL;
	//建立其它元素
	while(i<len)
	{
		//建立並初始化下一個節點
		next=malloc(SIZE);
		next->num=i;
		next->next=NULL;

		p->next=next;//將上一個節點連結到下一個節點
		p=next;//跳轉到下一個節點
		i++;
	}
	//輸出連結串列
	p=head;
	do
	{
		testNode(p);
		p=p->next;
	} while(p!=NULL);
	
	return 0;
}

沒有封裝不利於重用和編寫大量的程式碼,但很多高階語言的虛擬機器在底層也是這麼實現的。

共用體

共用體與結構體的區別是所有成員共享一段記憶體空間,這顯然是為節省記憶體而設計的,我們先來看看共用體的定義格式:
union 型別名稱
{
成員1;
成員2;

};
與結構體一樣,共用體的成員可以是任何資料型別,但在同一時刻只能存放一種資料型別,共用體所佔記憶體大小是其計算出的能夠容納最大的成員大小,但不一定等於最大成員型別的大小,有時候會大一些。例如我們將月份以不同的格式存放:

#include<stdio.h>

union Month
{
	short shortType;
	char charType[5];
};

int main(int argc, char* argv[])
{
	union Month m={2}; //或者是union Month m={“二月”};
	printf("%d,%d\n",sizeof(union Month),sizeof(m));
	printf("%d,%d\n",m,&m);
	strcpy(m.charType,"一月");
	puts(m.charType);
}

宣告共用體變數時關鍵字union也不可缺少,從上面程式碼結果可以看到,初始化共用體變數時可以是任何一個成員的的值,也可以不指定值,成員charType的長度為5,測量Month和m大小為6,因此共用體不一定就是最大成員的大小,實際大小應該使用sizeof()函式求出。C99允許相同型別的共用體賦值,引數按值傳遞,因此在使用上和struct沒有多大區別,在實際運用中僅將變數用不同資料型別存放意義並不大,它更大的作用還是節省記憶體,例如:

struct Student
{
	int num;
	char name[20];
};

struct Teacher
{
	int num;
	char name[20];
};

union Pool
{
	struct Student s[100];
	struct Teacher t[100];
};

臨時池Pool可以最大容納100個學生或者100個老師,除此之外不能容納別的資料型別,我們可以將池內的資料複製到指定的地方,這有點像剪下板,可以複製的型別以及內容由我們自己定義,當複製新的內容時原來內容會被覆蓋。即便用作臨時池,共用體也沒有動態記憶體有優勢,因為它的空間固定且用完也不一定能回收,因此在實際應用中很少看到共用體,多見於微控制器。

位域

位域不是一種資料型別而是C語言壓縮資料的一種方案,C語言中最小的整數型別是short,也就是2位元組,2位元組最大表示的範圍是65535,實際工作中可能用不了這麼大的範圍,例如enum和bool,通常enum用一個位元組儲存足矣,bool用一個二進位制位儲存就行,使用short和int儲存會造成空間的浪費,C語言支援在結構體中使用位域來節省空間,方法是在成員後面用冒號指定所佔的位,例如:

#include<stdio.h>

struct Student
{
	char name[20];
	int age:8;
	int sex:1;
};

int main(int argc, char* argv[])
{
	printf("%d",sizeof(struct Student));
	return 0;
}

對於一個學生的年齡來說不會超過255,所以將age指定位8bit,對於性別來說不會超過1bit,原本Student需要至少28個位元組儲存,測試的結果為24,壓縮了4個位元組,為什麼結果不是22呢?因為C語言標準規定:

  1. 位域的寬度不能超過它所依附的資料型別的長度
    這裡age和sex型別為int,因此指定的位寬度不成超過sizeof(int)
  2. 只有有限的幾種資料型別可以用於位域
    C99支援short,int,unsigedint,bool,實際上現在的編譯器額外還支援char以及 enum 型別
  3. 當相鄰成員的型別相同時,如果它們的位寬之和小於型別的 sizeof() 大小,那麼後面的成員緊鄰前一個成員儲存,直到不能容納為止
  4. 如果兩個緊鄰成員的位寬之和大於型別的 sizeof 大小,那麼後面的成員將從新的儲存單元開始
  5. 當使用位域時,由於位域成員不佔用完整的位元組,所以不能使用&獲取位域成員的地址。
    雖然不能獲取位域成員的地址,但用訪問成員值是沒有問題的。

當相鄰的成員型別不同時,每個編譯器也會採取不同的方案,GCC 會壓縮儲存,而 VC/VS 不會,加上成員之間本來就有間隙,使用了位域的結構體大小完全由編譯器決定,相對於union使用的覆蓋技術,位域則採用另一種思路來節省空間。

列舉

當我們需要通過使用者輸入獲取資訊時就不可避免的使用列舉,列舉將變數的值限定在一個範圍,例如性別和日期,下例定義一個表示星期的列舉型別:

#include<stdio.h>
enum Weekday{sun,mon,tue,wen,thu,fri,sat};
enum Weekday w=mon;

int main(int argc, char* argv[])
{
	printf("%d,%d\n",w,mon);
	return 0;
}

enum是定義列舉型別的關鍵字,在定義和宣告變數時都不能省略,C語言使用原始的方式處理列舉型別,它將列舉值視作常量,通過0,1,2,3…進行初始化,如果我們不想按照順序進行初始化,也可以寫成enum Weekday{sun=7,mon=1,tue=2,wen=3,thu=4,fri=5,sat=6},列舉定義完後常量可以直接使用,例如:

#include<stdio.h>

enum Weekday{sun,mon,tue,wen,thu,fri,sat};
enum Weekday w=mon;

int main(int argc, char* argv[])
{
	switch(w)
	{
		case sun:
			puts("sun");break;
		case mon:
			puts("mon");break;
		case tue:
			puts("tue");break;
		case wen:
			puts("wen");break;
		case thu:
			puts("thu");break;
		case fri:
			puts("fri");break;
		case sat:
			puts("sat");break;
	}
	return 0;
}

這種最原始的處理方式雖然使用和處理起來很高效,但會造成命名汙染,定義一個列舉型別相當於同時定義了一堆常量,為了避免重名,可以將列舉值命名為Weekday_Mon,Weekdayd_Tue…,這樣重新命名的機率就大大減小了。

複雜資料型別總結

C作為程式導向的語言,它所包含的複雜資料型別也是原始性的,這些原始內容很純粹,不包含任何不相關的內容也不對其它型別形成依賴,所佔據記憶體對我們來說也是透明的,而對於高階語言,結構體、列舉甚至陣列都使用類來定義,而且很多高階語言為了將物件導向做的徹底,強調一切皆為物件,因此陣列、關聯陣列都是對基本物件的擴充套件,它們包含基本物件的功能,內部實現不得知,記憶體對我們也不開放,變數在賦值時都是是按引用傳遞的,因此學過高階語言的人轉頭回來學習C語言很容易將結構體也當作資料集合,認為它是按引用傳遞的,也很容易在結構體中初始化成員值,把它當作類别範本來處理。C語言原始的資料集合只有陣列,結構體是一個原始的複雜資料型別,只有使用結構體構建的自定義資料結構才能稱為資料集合。對於節省資源來說,struct和union採用兩種不同的思路,一種是壓縮空間,一種是提升空間的利用率,對於現代不缺記憶體和硬碟的計算機來說是沒有必要的,但對於資源緊缺的微控制器、嵌入式系統是可以起到一定作用。

相關文章