自定义数据类型
C 语言允许用户根据需要定义自己的数据类型,以更好地组织和表示数据。常见的自定义数据类型包括枚举、结构体、共用体,以及使用 typedef
创建类型别名。
枚举类型 (enum)
枚举类型用于定义一组命名的整数常量,使代码更具可读性。
枚举类型允许为一组整数值赋予有意义的名称。
enum Color { RED, GREEN, BLUE }; // 默认从 0 开始赋值,RED=0, GREEN=1, BLUE=2
enum Status { OK = 0, WARNING = 1, ERROR = 2 }; // 显式赋值
使用示例:
#include <stdio.h>
enum Day { MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY };
int main() {
enum Day today = WEDNESDAY;
if (today == WEDNESDAY) {
printf("Today is Wednesday.\n"); // 输出:Today is Wednesday.
}
printf("Value of FRIDAY: %d\n", FRIDAY); // 输出:Value of FRIDAY: 4
return 0;
}
与 C++ 的区别:
C++11 引入了作用域枚举 (enum class
),解决了传统 C 语言枚举的一些问题(如命名冲突、隐式转换为整数)。C 语言的枚举常量属于全局作用域(或定义它们的作用域),并且可以隐式转换为 int
。
enum Color { RED_C, GREEN_C, BLUE_C };
enum State { ACTIVE, INACTIVE };
int main() {
enum Color c = RED_C;
// enum State s = RED_C; // 在 C 中通常不会报错,但逻辑上不推荐
int val = GREEN_C; // C 语言中枚举可以隐式转换为 int
printf("Value: %d\n", val);
return 0;
}
在 C 语言中,枚举常量本质上是 int
类型的常量。虽然方便,但也可能导致命名冲突和类型混淆。在 C++ 中推荐使用 enum class
。
结构体 (struct)
结构体允许将不同类型的数据项组合成一个单一的逻辑单元。
结构体是一种用户定义的数据类型,用于封装一组相关的数据成员。
struct Point {
int x;
double y;
char label;
};
初始化与访问:
#include <stdio.h>
#include <string.h>
struct Person {
char name[50];
int age;
};
int main() {
// 初始化 (C99 及以后支持指定初始化器)
struct Person p1 = {"Alice", 30};
struct Person p2;
strcpy(p2.name, "Bob");
p2.age = 25;
// 访问成员
printf("Person 1: Name=%s, Age=%d\n", p1.name, p1.age);
printf("Person 2: Name=%s, Age=%d\n", p2.name, p2.age);
// 结构体指针访问
struct Person* ptr_p = &p1;
printf("Pointer Access: Name=%s, Age=%d\n", ptr_p->name, ptr_p->age);
// C 语言中声明结构体变量需要带 struct 关键字,除非使用了 typedef
// Point pt; // 错误,除非 Point 是 typedef 定义的别名
struct Point pt_var;
pt_var.x = 10;
return 0;
}
与 C++ 的区别:
struct
vsclass
: C 语言没有class
关键字。C++ 中struct
和class
主要区别在于默认访问权限(struct
默认public
,class
默认private
),并且class
更常用于包含成员函数(方法)。- 声明: 在 C++ 中,定义结构体后可以直接使用结构体名声明变量(如
Point pt;
),而在 C 语言中通常需要带struct
关键字(如struct Point pt;
),除非使用了typedef
。
共用体 (union)
共用体允许在相同的内存位置存储不同的数据类型,但一次只能有效使用其中一种类型。
共用体是一种特殊的数据结构,其所有成员共享同一块内存空间。共用体的大小由其最大的成员决定。
union Data {
int i;
float f;
char str[20];
};
使用示例:
#include <stdio.h>
#include <string.h>
union Data {
int i;
float f;
char str[20];
};
int main() {
union Data data;
data.i = 10;
printf("data.i: %d\n", data.i); // 输出:data.i: 10
data.f = 220.5f;
printf("data.f: %f\n", data.f); // 输出:data.f: 220.500000
// 此时访问 data.i 的值是未定义的 (取决于内存表示)
// printf("data.i after float assignment: %d\n", data.i);
strcpy(data.str, "C Programming");
printf("data.str: %s\n", data.str); // 输出:data.str: C Programming
// 此时访问 data.i 或 data.f 的值是未定义的
printf("Size of union Data: %zu\n", sizeof(union Data)); // 输出:Size of union Data: 20 (或更大,取决于对齐)
return 0;
}
- 同一时间只能有效访问最后一次赋值的成员。
- 访问非活动成员会导致未定义行为。
- 共用体常用于节省内存或实现特定技巧(如查看数据的不同表示),但需谨慎使用以保证类型安全。
别名 (typedef)
typedef
用于为已有的数据类型创建一个新的名称(别名),以提高代码的可读性和简化复杂类型声明。
typedef
是一种创建类型别名的机制,它不创建新的类型,只是为现有类型提供一个同义词。
typedef existing_type new_type_name;
使用示例:
#include <stdio.h>
#include <stdlib.h> // for malloc
// 为基本类型创建别名
typedef unsigned long ulong;
typedef const char* CString;
// 为结构体创建别名 (常用方式)
struct Point {
int x, y;
};
typedef struct Point Vec2D;
// 或者合并定义和 typedef
typedef struct Vector {
double x, y, z;
} Vector3D;
// 为数组类型创建别名
typedef int IntArray[10];
// 为函数指针创建别名
typedef void (*FuncPtr)(int);
void printNumber(int n) {
printf("Number: %d\n", n);
}
int main() {
ulong largeNum = 1234567890UL;
CString message = "Hello Typedef";
Vec2D v = {10, 20}; // 使用别名声明,无需 struct 关键字
Vector3D vec3 = {1.0, 2.0, 3.0};
IntArray numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 0};
FuncPtr myFunc = printNumber;
printf("ulong: %lu\n", largeNum);
printf("CString: %s\n", message);
printf("Vec2D: (%d, %d)\n", v.x, v.y);
printf("Vector3D: (%f, %f, %f)\n", vec3.x, vec3.y, vec3.z);
printf("IntArray element: %d\n", numbers[2]);
myFunc(42); // 调用函数指针
return 0;
}
与 C++ 的区别:
C++11 引入了 using
关键字作为 typedef
的现代替代方案,语法更清晰,并且能更好地处理模板别名。C 语言没有 using
别名声明。
// C++ using example
using ulong_alias = unsigned long;
template<typename T>
using Matrix = std::vector<std::vector<T>>; // C typedef 无法直接定义模板别名
typedef
在 C 语言中非常有用,特别是为结构体、共用体和函数指针定义别名,可以显著提高代码的可读性。
预处理器Preprocessor
C语言中的预处理器(Preprocessor)是编译器的一个重要组成部分,它在实际编译之前对源代码进行处理。预处理器的主要作用是对源代码进行宏替换、文件包含、条件编译等操作。
预处理器指令以 # 开头,常见的预处理指令包括: (1) 宏定义 (#define) (2) 文件包含 (#include) (3) 条件编译 (#if, #ifdef, #ifndef, #elif, #else, #endif) (4) 其他指令 (如 #error, #warning, #pragma)
预处理器并不是C语言的一部分,而是一个独立的文本处理工具。它会在编译器真正开始编译之前,根据预处理指令对源代码进行修改。预处理器不会理解C语言的语法,只是简单地对文本进行替换或操作
宏定义 (#define
)
宏定义用于创建符号常量或简单的代码片段替换。
#define
指令用于定义宏。宏分为对象式宏(Object-like Macro)和函数式宏(Function-like Macro)。
#include <stdio.h>
#define PI 3.14159
#define BUFFER_SIZE 1024
int main() {
double radius = 5.0;
double area = PI * radius * radius;
char buffer[BUFFER_SIZE];
printf("Area: %f\n", area);
printf("Buffer size: %d\n", BUFFER_SIZE);
return 0;
}
函数式宏:
#include <stdio.h>
#define MAX(a, b) ((a) > (b) ? (a) : (b))
#define SQUARE(x) ((x) * (x))
int main() {
int max_val = MAX(10, 20); // 展开为 ((10) > (20) ? (10) : (20))
int sq_val = SQUARE(5 + 2); // 展开为 ((5 + 2) * (5 + 2))
printf("Max: %d\n", max_val); // 输出:Max: 20
printf("Square: %d\n", sq_val); // 输出:Square: 49
return 0;
}
- 副作用:函数式宏的参数可能被多次求值,导致意外的副作用。
c
int x = 5; int y = SQUARE(x++); // 展开为 ((x++) * (x++)), 行为未定义或不符合预期 printf("x=%d, y=%d\n", x, y); // 输出可能不是预期的 x=6, y=25
- 类型不安全:宏是简单的文本替换,不进行类型检查。
- 括号问题:宏定义中应谨慎使用括号,以避免运算符优先级问题。
#define SQUARE(x) x * x
是错误的,SQUARE(5 + 2)
会展开为5 + 2 * 5 + 2
。 - 调试困难:宏展开后的代码可能不易调试。
取消宏定义:使用 #undef
可以取消已定义的宏。
#define DEBUG_MODE
// ... some code ...
#undef DEBUG_MODE
- 常量: 使用
const
或constexpr
定义常量,它们具有类型安全,作用域规则更清晰。 - 函数: 使用内联函数 (
inline
) 或普通函数替代函数式宏,它们类型安全,行为更可预测,调试更方便。
- 对于常量,优先使用
const
(C/C++) 或枚举。 - 对于简单的函数,优先使用静态内联函数 (
static inline
) 或普通函数。 - 仅在确实需要文本替换或无法用其他方式实现时才考虑使用宏,并务必注意其潜在问题。
文件包含 (#include
)
#include
指令用于将其他文件的内容插入到当前文件中,通常用于包含头文件。
#include
指令告诉预处理器读取指定文件的内容,并将其插入到指令所在的位置。
两种形式:
#include <filename.h>
:用于包含标准库头文件或系统提供的头文件。预处理器通常在系统指定的包含路径中查找。c#include <stdio.h> // 标准输入输出库 #include <stdlib.h> // 标准库函数 (malloc, exit等) #include <string.h> // 字符串处理函数 #include <math.h> // 数学函数
#include "filename.h"
:用于包含用户自定义的头文件。c#include "myheader.h" // 包含同目录下的自定义头文件 #include "utils/helpers.h" // 包含相对路径下的头文件
头文件守卫 (Header Guards):为了防止同一个头文件被多次包含导致重定义错误,必须使用头文件守卫。
// myheader.h
#ifndef MYHEADER_H // 如果 MYHEADER_H 未定义
#define MYHEADER_H // 则定义 MYHEADER_H
// --- 头文件内容开始 ---
struct MyStruct {
int data;
};
void myFunction(int x);
// --- 头文件内容结束 ---
#endif // MYHEADER_H
#pragma once
:许多现代 C/C++ 编译器支持 #pragma once
指令,它具有与头文件守卫相同的功能,但语法更简洁,且可能编译更快。但它不是 C/C++ 标准的一部分,可移植性略差于传统头文件守卫。
// myheader.h
#pragma once
// 头文件内容
struct MyStruct {
int data;
};
void myFunction(int x);
- 始终 使用头文件守卫 (
#ifndef/#define/#endif
) 或#pragma once
来保护你的头文件。 - 优先使用
#include <...>
包含标准库和第三方库头文件,使用#include "..."
包含项目内部的头文件。 - 头文件中通常只包含声明(函数原型、结构体/联合/枚举定义、
extern
变量声明、宏定义、typedef
),避免包含函数定义或全局变量定义(除非是static
或inline
),以防止链接错误。
条件编译
条件编译指令允许根据预处理时定义的条件,选择性地编译代码块。
条件编译指令(如 #if
, #ifdef
, #ifndef
, #elif
, #else
, #endif
)允许在编译时根据特定条件包含或排除代码段。
#ifdef MACRO_NAME
:如果MACRO_NAME
已通过#define
定义,则编译后续代码。#ifndef MACRO_NAME
:如果MACRO_NAME
未被定义,则编译后续代码。#if constant_expression
:如果constant_expression
求值为非零(真),则编译后续代码。表达式必须在预处理时就能求值。#elif constant_expression
:else if
的形式,在前一个#if
或#elif
条件为假时检查此条件。#else
:如果前面的#if
/#ifdef
/#ifndef
/#elif
条件都为假,则编译此部分代码。#endif
:标记条件编译块的结束。
使用场景:
- 平台特定代码:根据不同的操作系统或编译器编译不同的代码。
c
#include <stdio.h> #ifdef _WIN32 // Windows 特定代码 #include <windows.h> const char* os_name = "Windows"; #elif defined(__linux__) // Linux 特定代码 #include <unistd.h> const char* os_name = "Linux"; #elif defined(__APPLE__) // macOS 特定代码 const char* os_name = "macOS"; #else // 其他平台代码 const char* os_name = "Unknown OS"; #endif int main() { printf("Running on: %s\n", os_name); return 0; }
- 调试代码:只在调试模式下编译某些代码。
c
#include <stdio.h> // 在编译时通过 -DDEBUG 或在代码中定义 // #define DEBUG 1 int main() { int x = 10; #ifdef DEBUG fprintf(stderr, "Debug: Variable x = %d at line %d\n", x, __LINE__); #endif printf("Program continues...\n"); return 0; }
- 功能开关:根据宏定义启用或禁用某些功能。
c
#include <stdio.h> #define ENABLE_FEATURE_X 1 // 设为 0 或注释掉则禁用 #if ENABLE_FEATURE_X void featureX() { printf("Feature X is enabled.\n"); } #else void featureX_disabled() { printf("Feature X is disabled.\n"); } #endif int main() { #if ENABLE_FEATURE_X featureX(); #else featureX_disabled(); #endif return 0; }
defined
运算符:可以在 #if
和 #elif
中使用 defined(MACRO_NAME)
或 defined MACRO_NAME
来检查宏是否已定义,这比 #ifdef
更灵活,允许组合条件。
#define OPTION_A
// #define OPTION_B 10
#if defined(OPTION_A) && (!defined(OPTION_B) || OPTION_B < 5)
printf("Condition met.\n");
#else
printf("Condition not met.\n");
#endif
条件编译是控制代码在不同环境下行为的强大工具,但过度使用会降低代码的可读性和可维护性。尽量将平台相关或配置相关的代码隔离到特定的文件或模块中。
文件I/O及相关函数
在C语言中,所有文件操作都是基于流(stream)的概念进行的。流是一个抽象的概念,代表一个数据序列,无论是从文件读取还是向文件写入。每个流都与一个FILE*
类型的指针相关联,这个指针指向内部的文件控制块,包含了文件的状态和位置等信息。
C语言提供了一套完整的文件操作函数,用于文件的创建、打开、读写、定位和关闭。这些函数定义在标准库头文件 <stdio.h>
中。
文件打开与关闭
-
fopen
函数用于打开文件,接受两个参数:文件名
和打开模式
-
fclose
当不再需要访问文件时,应该关闭文件以释放资源, 接受一个参数:文件指针
FILE *fopen(const char *filename, const char *mode);
int fclose(FILE *stream);
模式 | 描述 |
---|---|
"r" |
只读模式。打开一个已存在的文本文件用于读取数据,文件指针位于文件开头。如果文件不存在,则函数返回 NULL 并设置错误标志。适用于只读操作。 |
"w" |
写模式。创建一个新的文本文件或覆盖一个已存在的文件进行写入操作,文件指针位于文件开头。如果文件存在,其内容会被清空。如果文件不存在,则创建新文件。 |
"a" |
追加模式。打开一个文本文件用于在文件末尾追加数据,文件指针位于文件末尾。如果文件不存在,则创建新文件。尝试在其他位置写入会导致数据追加到文件末尾。 |
"r+" |
读写模式。打开一个已存在的文本文件用于读取和写入数据,文件指针位于文件开头。如果文件不存在,则函数返回 NULL 并设置错误标志。适合需要同时读取和修改文件的应用。 |
"w+" |
读写模式(覆盖)。创建一个新的文本文件或覆盖一个已存在的文件进行读取和写入操作,文件指针位于文件开头。如果文件存在,其内容会被清空。如果文件不存在,则创建新文件。 |
"a+" |
读写模式(追加)。打开一个文本文件用于在文件末尾追加数据并允许读取,文件指针位于文件末尾。即使使用 fseek() 移动文件指针,所有写操作仍然会在文件末尾进行。如果文件不存在,则创建新文件。 |
#include <stdio.h>
int main() {
FILE *fp = fopen("example.txt", "w");
if (fp == NULL) {
printf("Failed to open file!\n");
return 1;
}
// 文件操作...
fclose(fp); // 关闭文件
return 0;
}
- 始终检查
fopen
的返回值是否为NULL
- 确保每个打开的文件最终都被关闭
- 在写入模式下打开文件会清空原有内容
文本文件读写
C语言提供了多种文件读写函数,适用于不同场景:
1. 单个字符读写 (fgetc
/fputc
)
-
fgetc(FILE *stream)
: 从文件中读取一个字符 -
fputc(int c, FILE *stream)
: 向文件中写入一个字符
#include <stdio.h>
int main() {
FILE *fp = fopen("example.txt", "w+");
if (fp == NULL) return 1;
// 写入单个字符
fputc('A', fp);
// 重置文件指针到开头
rewind(fp);
// 读取单个字符
int ch = fgetc(fp);
printf("Read character: %c\n", ch);
fclose(fp);
return 0;
}
2. 行读写 (fgets
/fputs
)
-
fgets(char *str, int n, FILE *stream)
用于从文件中读取一行文本,最多读取n-1
个字符。 -
fputs(const char *str, FILE *stream)
用于向文件写入一个字符串,不自动添加换行符。
#include <stdio.h>
int main() {
FILE *fp = fopen("example.txt", "w+");
if (fp == NULL) return 1;
// 写入一行文本
fputs("Hello, World!\n", fp);
rewind(fp);
// 读取一行(最多读取99个字符)
char buffer[100];
fgets(buffer, sizeof(buffer), fp);
printf("Read line: %s", buffer);
fclose(fp);
return 0;
}
3. 格式化读写 (fscanf
/fprintf
)
#include <stdio.h>
int main() {
FILE *fp = fopen("data.txt", "w+");
if (fp == NULL) return 1;
// 格式化写入
fprintf(fp, "%d %f %s\n", 42, 3.14, "Pi");
rewind(fp);
// 格式化读取
int i;
float f;
char str[20];
fscanf(fp, "%d %f %s", &i, &f, str);
printf("Read: %d, %f, %s\n", i, f, str);
fclose(fp);
return 0;
}
二进制文件读写
二进制文件读写 (fread
/fwrite
):
-
fread(void *ptr, size_t size, size_t nmemb, FILE *stream)
:从文件中读取数据到内存块中。 -
fwrite(const void *ptr, size_t size, size_t nmemb, FILE *stream)
:将内存块中的数据写入文件。
参数名 | 含义 |
---|---|
ptr |
指向内存块的指针: - 对于 fread ,数据从文件读取到该内存块中。- 对于 fwrite ,数据从该内存块写入文件。 |
size |
每个元素的大小(以字节为单位)。 |
nmemb |
要读取或写入的元素数量。 |
stream |
文件流指针,指向一个已打开的文件(通过 fopen 打开)。 |
#include <stdio.h>
struct Data {
int id;
double value;
};
int main() {
FILE *fp = fopen("data.bin", "wb+");
if (fp == NULL) return 1;
struct Data data = {1, 3.14};
// 写入二进制数据
fwrite(&data, sizeof(struct Data), 1, fp);
rewind(fp);
// 读取二进制数据
struct Data read_data;
fread(&read_data, sizeof(struct Data), 1, fp);
printf("Read: id=%d, value=%f\n", read_data.id, read_data.value);
fclose(fp);
return 0;
}
文件定位函数
文件位置指针指示文件中当前读写位置,可以通过相关函数进行控制和查询
ftell(FILE *stream)
:返回当前文件位置fseek(FILE *stream, long offset, int whence)
:设置文件位置rewind(FILE *stream)
:重置文件位置到开头
SEEK_SET
:从文件开头开始SEEK_CUR
:从当前位置开始SEEK_END
:从文件末尾开始
#include <stdio.h>
int main() {
FILE *fp = fopen("example.txt", "w+");
if (fp == NULL) return 1;
fputs("This is a test file.", fp);
// 获取文件大小
fseek(fp, 0, SEEK_END);
long size = ftell(fp);
printf("File size: %ld bytes\n", size);
// 跳转到第5个字节
fseek(fp, 5, SEEK_SET);
// 读取从第5个字节开始的内容
char buffer[100];
fgets(buffer, sizeof(buffer), fp);
printf("Content from position 5: %s\n", buffer);
fclose(fp);
return 0;
}
- 始终检查文件操作函数的返回值
- 使用二进制模式 (
"b"
) 处理非文本文件 - 对于大文件,考虑使用缓冲读写
- 注意不同平台上的换行符差异
错误处理 (feof
/ferror
)
-
feof(FILE *stream)
:检测是否到达文件末尾。 -
ferror(FILE *stream)
: 检测是否存在文件错误。
#include <stdio.h>
int main() {
FILE *fp = fopen("nonexistent.txt", "r");
if (fp == NULL) {
perror("Error opening file");
return 1;
}
// 读取文件内容
char ch;
while ((ch = fgetc(fp)) != EOF) {
putchar(ch);
}
// 检查是否到达文件末尾
if (feof(fp)) {
printf("\nReached end of file\n");
}
// 检查是否发生错误
if (ferror(fp)) {
printf("Error reading file\n");
}
fclose(fp);
return 0;
}
EOF
是一个特殊值(通常是 -1),表示文件结束perror
函数可以输出描述性的错误信息- 不要仅依赖
feof
作为循环条件,这可能导致多读一次