C杂谈

过时内容, 待重写

请注意: 这是一个过时的blog, 内容问题较多, 全新的C杂谈在编写中.

(C11 - GCC13.1.0/MSVC1937)
内容不分先后顺序, 请按需查阅.

CMAKE

使用CLion或VS时, 可以考虑如下CMAKE配置; 会自动获取项目名与文件名, 每次修改项目名文件名或新建文件时, 只需重新加载CMAKE.
请注意, 如下写法并非现代用法.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
cmake_minimum_required(VERSION 3.26)

get_filename_component(ProjectId ${CMAKE_CURRENT_SOURCE_DIR} NAME)
string(REPLACE " " "_" ProjectId ${ProjectId})
project(${ProjectId} C)

set(CMAKE_C_STANDARD 11)

file(GLOB files "${CMAKE_CURRENT_SOURCE_DIR}/*.c")
foreach(file ${files})
    get_filename_component(name ${file} NAME)
    add_executable(${name} ${file})
endforeach()

添加头文件:

1
include_directories("头文件相对路径")

此外, 还可以修改C标准版本:

1
set(CMAKE_C_STANDARD 99)

一个方便调试的头文件

该头文件中定义用变长参数定义了一系列输出函数, 可以直接采用"PRINT_TYPE(variable);“的形式来打印对应类型的变量并换行.
传入数组时, 传入的是数组的首位指针, 还需在外部获取数组长度一并传入.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#ifndef BASICC_IO_UTILS_IO_UTILS_H_
#define BASICC_IO_UTILS_IO_UTILS_H_

#include <stdio.h>
#include <limits.h>

void PrintBinary(unsigned int value);

//#define PRINT_METADATA
#ifdef PRINT_METADATA
# define PRINTLNF(format, ...) printf("("__FILE__":%d) %s: "format"\n", __LINE__, __FUNCTION__ , ##__VA_ARGS__)
#else
# define PRINTLNF(format, ...) printf(format"\n", ##__VA_ARGS__)
#endif

#define PRINT_CHAR(char_value) PRINTLNF(#char_value": %c", char_value)
#define PRINT_WCHAR(char_value) PRINTLNF(#char_value": %lc", char_value)
#define PRINT_INT(int_value) PRINTLNF(#int_value": %d", int_value)
#define PRINT_LONG(long_value) PRINTLNF(#long_value": %ld", long_value)
#define PRINT_LLONG(long_value) PRINTLNF(#long_value": %lld", long_value)
#define PRINT_BINARY(int_value) PrintBinary((unsigned int) int_value);
#define PRINT_HEX(int_value) PRINTLNF(#int_value": %#x", int_value)
#define PRINT_BOOL(bool_value) PRINTLNF(#bool_value": %s", bool_value ? "true" : "false")
#define PRINT_DOUBLE(double_value) PRINTLNF(#double_value": %g", double_value)
#define PRINT_STRING(string_value) PRINTLNF(#string_value": %s", string_value)

#define PRINT_ARRAY(format, array, length) \
{ int array_index; \
for (array_index = 0; array_index < length; ++array_index) { \
  printf(format, array[array_index]); \
};\
printf("\n"); }

#define PRINT_INT_ARRAY_LN(array, length) \
{ int i; \
for (i = 0; i < length; ++i) { \
  PRINTLNF(#array"[%d]: %d", i, array[i]); \
}}

#define PRINT_INT_ARRAY(array, length) PRINT_ARRAY("%d, ", array, length)
#define PRINT_CHAR_ARRAY(array, length) PRINT_ARRAY("%c, ", array, length)
#define PRINT_DOUBLE_ARRAY(array, length) PRINT_ARRAY("%g, ", array, length)

#endif //BASICC_IO_UTILS_IO_UTILS_H_

来自: https://www.bennyhuo.com/

赋值语句

在赋值语句中, 左值("=“左边)必须是一个变量或说内存, 不能为数值; 右值("=“右边)可以为数值或变量.

1
2
3
4
5
int *p = &a;
*p = 2; // 合法
*(p+1) = 3; // 合法
&a = p; // 非法, &a为变量a的地址, 是数值
*p+1 = 4; // 非法, *p解引用, *p+1为p指向的内存中的数据+1, 是数值
1
2
3
4
5
int array[] = {0,1,2,3};
int *p = array;
*p = 10; // 合法
*p++ = 20; // 合法
*++p = 30; // 合法

副作用: 表达式不仅计算数值还修改了环境; 序点间对对象同时读写, 则读必须为了写, 否则为未定义行为.

1
2
3
4
5
i = i + 1; // 合法
++i; // 合法
i = (++i)+(i++); // 未定义
a[i] = i++; // 未定义
i = 1 + i++; // 未定义

参考: https://zh.cppreference.com/w/c/language/eval_order

不同数据类型的运算

该部分内容待更新

整型提升:
表达式中存在(unsigned) int, 则 (unsigned) char, (unsigned) short int, enum 均转换为int, 不足时转换为 unsigned int.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include<stdio.h>

int main(){
    char ch = 'a';
    short st = 4;
    int i = 0;
    
    printf("%llu %llu\n",sizeof(ch+st),sizeof(i));
    return 0;
}

隐式转换:
算术表达式: 低类型转换为高类型, int -> unsigned int -> long -> unsigned long -> long long -> unsigned long long -> float -> double -> long double;
赋值表达式: 右值转换为左值;
函数传参: 实参转换为形参;
函数返回值: 表达式转换为返回值.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include<stdio.h>

int main(){
    int i = 0;
    unsigned int ui = 0;
    long l = 1;
    long long ll = 2;
    float f = 0.1f;
    double d = 0.2f;

    printf("%llu %llu\n",sizeof(i+l),sizeof(l));
    printf("%llu %llu %llu \n",sizeof(l+ll),sizeof(ui+ll),sizeof(ll));
    printf("%llu %llu\n",sizeof(ll*f),sizeof(f));
    printf("%llu %llu\n",sizeof(f+d),sizeof(d));

    short s = d*ll;
    printf("%llu\n",sizeof(s));
    return 0;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include<stdio.h>

double func(int a){
    printf("%llu\n",sizeof(a));
    return a;
}

int main(){
    double a = 2.9f;
    printf("%llu\n",sizeof(func(a)));
    printf("%f", func(a));
    return 0;
}

强制类型转换: (类型名) 表达式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
#include <stdio.h>

int main(){
    int a = 17, b = 5;
    double c = a/b;
    double d = (double)a/b;
    
    printf("%f %f",c,d);
    return 0;
}

应用: 模平方时防止越界

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <stdio.h>

int main(){
    int a = 104560;
    int m = 1307;
    int b = (a*a)%m;
    int c = (1LL*a*a)%m;
    int d = ((long long)a*a)%m;

    printf("%d %d %d",b,c,d);
    return 0;
}

内存对齐

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>

int main(){
    typedef struct {
        char sch1;
        char sch2;
        int si;
        short ss;
        double sd;
    } Align;
    char ch;
    int i;
    short s;
    double d;

    printf("%llu\n", sizeof(Align));
    printf("%llu\n", sizeof(ch)+ sizeof(ch)+ sizeof(i)+ sizeof(s)+ sizeof(d));
    return 0;
}

发现结构体内部变量占用空间加起来仅为16个字节, 但结构体占用24个字节, 这就涉及到结构体的内存对齐.

memoryAlignment

char占用1字节, 对齐到1的倍数; int占用4字节, 对齐到4的倍数; double占用8字节, 对齐到8的倍数.
尽管内存以字节为基本单位, 但CPU会以4字节, 8字节, 甚至16字节进行访问; 在没有对齐机制时, 访问到正确的变量位置需要额外操作.
内存对齐系数由编译器决定, 结构体占用内存为对齐系数倍数.
GCC和MSVC中默认对齐系数均为4, 可以通过预编译”#pragma pack(n)“来改变.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#include <stdio.h>
#pragma pack(2)

int main(){
    typedef struct {
        char sch1;
        char sch2;
        int si;
        short ss;
        double sd;
    } Align;
    printf("%llu\n", sizeof(Align));
    return 0;
}

但通常不建议改变对齐系数, 为了使结构体占用更小内存, 应当将占用较小的变量统一排在前面, 如将上述结构体修改为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
int main(){
    typedef struct {
        char sch1;
        char sch2;
        short ss;
        int si;
        double sd;
    } Align;
    printf("%llu\n", sizeof(Align));
    return 0;
}

深入理解指针

声明变量时, 如"int a;”, 在运行时会为变量a开辟一块内存空间, 但值不能确定. 用scanf写入时, 用”&a"表示写入到变量a所在的地址. 地址的数据类型即为指针类型, 即指针类型的变量存放的数据为内存地址.

数据类型包含两个信息: 内存占用大小; 读写时遵循的规则. 定义一个指针类型时, 还需要给出指向的数据类型. 这是因为指针仅指向一个内存单元, 也就是CPU读写的起始位置(小端序下的数据低位); 只有给出指向的数据类型, CPU才能知道读写的终止位置和读写遵循的规则.

1
2
3
4
5
6
7
8
9
#include <stdio.h>

int main(){
    long long a = 0x0A0B0C0D;
    long long* p1 = &a;
    int* p2 = &a;
    // 在本行打断点调试, 在CLion或VS的Memory View中输入&a查看内存位置, 并对比Threads & Variables中p1,p2的值
    return 0;
}

此外, 指针类型的占用空间取决于CPU寻址方式, 与指向的数据类型无关.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#include <stdio.h>

int main(){
    char a;
    short b;
    int c;
    double d;
    char *pa = &a;
    short *pb = &b;
    int *pc = &c;
    double *pd = &d;
    printf("%llu %llu %llu %llu",sizeof(pa),sizeof(pb),sizeof(pc),sizeof(pd));
    return 0;
}

无歧义地, 指针类型和指针类型变量均简称为指针.

1
2
3
4
// 指针存放的是地址, 以下显然是合法的.
int a;
int *p = &a;
scanf("%d",p);

显然"int **p"意为指向"int *“类型的指针, “int ***p"意为指向"int **“类型的指针. “*p"含义为解引用, 即读写指针p指向的内存.

在使用指针时, 应当避免出现野指针, 即指向非法内存的指针.

1
2
3
// 使用未初始化的指针
int *p;
printf("%d",*p);
1
2
3
4
// 释放指针后未置空
int *p = (int*)malloc(sizeof(int));
free(p);
printf("%d",*p);
1
2
3
4
5
6
7
8
9
// 指向对象已消亡(超出变量作用域)
int *Address(void){
    int a = 10;
    return &a;
}
void getAddress(void){
    int *p = Address();
    printf("%d",*p);
}
1
2
3
4
5
// 指针赋值为无法读取的内存
int *p = (int *) 0x100000;
printf("%d",*p);
// Windows进程内存空间地址从0x400000开始
// 但考虑到平台不同时进程起始位置不同, 通常不建议赋值为具体地址

指针也可以进行加减运算, 含义为指向前后的内存, 移动单位取决于指向的数据类型.

1
2
3
4
5
6
7
8
#include <stdio.h>

int main(){
    double a = 0.0f;
    double *p = &a;
    printf("%u %u %u %llu",p-1,p,p+1,sizeof(a));
    return 0;
}

数组是连续的内存, 由此想到可以通过数组指针来调用数组, 同时数组变量名本质上也是指针.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
#include <stdio.h>

int main(){
    int array[] = {0,1,2,3};
    int *p = array;
    printf("%d %d %d %d %d\n",array[2],p[2],*(p+2),*(array+2),2[array]);
    // 考虑到可读性, 仅推荐array[2]和p[2]两种调用方式.

    int arr[4][2] = {0};
    printf("%x,%x",&arr[3][1],*(arr+3)+1);
    // C中二维数组行优先. 
    return 0;
}

指针自然也可以作为函数参数. 调用函数赋值时, 如"int a = func(b);”, CPU将函数返回值复制到寄存器中, 再将寄存器中的数据复制到要改变的变量, 这一过程也就体现为变量的作用域.
C可以返回结构体, 同时无法直接返回数组, 不使用指针时可以将数组包含在结构体中; 而若直接返回结构体, 结构体由于本身占用较大, 复制两次导致性能开销过大.
此时, 可以在函数外声明结构体或数组, 并将指向结构体的指针或数组传入.

函数也存在地址, 自然也可以有指向函数的指针. 但声明函数指针时, 运算优先级问题往往让人感到困惑: 如参数列表”()“的优先级高于”*”.
在分析函数声明时, 可以借助网站: https://cdecl.org/.

逻辑短路

对于逻辑运算符与”&&“和或”||“存在短路规则, “&&“前若已为假则忽略后续直接返回"fasle”, 同理”||“前若已为真则忽略后续直接返回"true”.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <stdio.h>

int main(){
    int a = 0, b = 1, c = 10, f, t;
    f = a && (b - ++c);
    t = b || (a - c++);
    printf("%d\n",c);
    f = b && (a - ++c);
    t = a || (b - c++);
    printf("%d\n",c);
    return 0;
}

运算优先级上, “()“和”++“都高于”&&“和”||”, 但第一次f和t赋值中有副作用的”++“一次也没有被执行, 第二次中都被执行了.

内存溢出和内存泄漏

内存溢出指申请空间没有足够的空间可以使用, 如声明"int"类型变量, 但赋值了超出"int"类型的数.

内存泄漏指申请使用内存后, 无法释放已经申请的内存空间; 内存泄漏累积就会造成内存溢出.
(在某些优化较差的游戏中, 如刺客信条: 大革命, 游玩时会突然卡死, 打开任务管理器发现内存占用率100%, 可能就是遇到了内存泄漏问题.)
对于个人用户, 内存泄漏也许不严重, 结束程序进程时会释放所有已分配的内存; 但对于服务器, 某些程序需要一直保持运行, 内存泄漏会不断累积并造成严重后果.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// 比较低级的错误: 分配的内存使用完后没有释放

// 指针重新赋值时, 原指向位置丢失
int *p = (int *)malloc(sizeof(int));
int *np = (int *)malloc(sizeof(int));
p = np;

// 内存释放时, 造成位置孤立
head -> next = node;
free(head);

// 分配了没有指针指向的内存
int *memory(){
    return (int *)malloc(sizeof(int));
}
void lost(){
    memory();
}

防止内存泄漏的核心思想是: 进行任何内存有关的操作时, 始终确保每个已分配的内存都有指针指向.

数组

有时在声明数组时无法确定数组长度, 需要传入参数.

1
2
3
4
5
6
7
#include <stdio.h>

int main(){
    int n = 3;
    int a[n];
    return 0;
}

GCC可以编译通过; 但MSVC不支持变长数组, 会报错.
可以考虑用内存分配函数实现, 调用方式同数组.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
#include <stdio.h>
#include <stdlib.h>

int main(){
    int n = 3;
    int *a = (int*)malloc(n*sizeof(int));
    a[0] = 1;
    printf("%d",a[0]);
    free(a);
    a = NULL;
    return 0;
}

C中二维数组按行存储, 故声明和传参时, 第二维度不可省略.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
int arr1[][3] = {{0, 1, 2}, {3, 4, 5}}; // 合法
int arr2[2][] = {{0, 1, 2}, {3, 4, 5}}; // 非法

int n = 3;
int arr1[][n] = {{0, 1, 2}, {3, 4, 5}}; // 同样GCC支持, MSVC不支持

#define MAXSIZE 10
void func(int arr[][MAXSIZE], int n, int m); // 合法
void func(int (*arr)[MAXSIZE], int n, int m); // 合法

void func(int arr[][], int n, int m); // 非法
void func(int arr[MAXSIZE][], int n, int m); // 非法
void func(int n, int m, int arr[n][m]); // 同样GCC支持, MSVC不支持
// 更多非法情况不一一列举

二维数组声明为"arr[][n]“时, arr[i][j] 的地址为”&arr[0][0]+n*i+j”; 而"arr"实际为指向 $n$ 块连续内存的指针.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#include <stdio.h>

int main() {
    int arr[2][3] = {{0, 1, 2}, {3, 4, 5}};
    int (*p)[3] = arr; // []的优先级高于*, 如写为*p[3]意为元素为指针的数组, 而非指向二维数组的指针
    printf("%x %x %x %x",sizeof(int),sizeof(*p),arr,arr+1);
    int *q = &arr[0][0]; // 比较和指针p的区别
    printf("%x %x %x %x\n",&arr[1][1],*(arr+1)+1,*(p+1)+1,q+1*3+1);
    // 四种方式得到的地址相同
    return 0;
}

在需要频繁使用动态二维数组时, 由于二维数组传参经常令人疑惑, 推荐使用一维数组模拟, 或如下方式:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <stdio.h>
#include <stdlib.h>

int** declare(int n, int m) {
    int** arr = (int**)malloc(sizeof(int*) * (n));
    for(int i = 0; i < n; ++i) {
        arr[i] = (int*)malloc(sizeof(int) * (m));
    }
    return arr;
}

void init(int** arr, int n, int m) {
    for(int i = 0; i < n; ++i) {
        for(int j = 0; i < m; ++j) {
            arr[i][j] = 0;
        }
    }
}

int main() {
    int n,m;
    scanf("%d %d", &n, &m);

    // 声明二维数组
    int** arr = declare(n, m);

    // 初始化二维数组
    init(arr, n, m);

    return 0;
}

C风格字符串

C风格的字符串必须以’\0’(即NULL或0)结尾. 本质为一个指向连续内存的头指针, 并以’\0’标志结尾. 必要时可以再设置一个尾指针, 指向结尾处的’\0'.

1
2
3
// 声明字符串: 初始化
char *str[1000] = "";
char *str = (char*)calloc(1000, 1);

但使用<string.h>库中的"strlen()“函数时, 返回的是字符串的有效长度: 如在第n-1位遇到NULL, 则返回n.

1
2
3
4
5
6
7
8
#include <stdio.h>
#include <string.h>

int main(){
    char a[20] = "string";
    printf("%llu",strlen(a));
    return 0;
}

若用"strlen()“得到其他字符串输出时, 想要得到相同有效长度的字符串需要声明长度为"strlen(string)+1”.
尽管不以"NULL"结尾有时不会得到错误的输出, 但应当避免不规范的使用.
仅以字符串值声明字符串时, 编译器会默认在结尾添加"NULL”, 无论字符串值末尾是否有”\0".

1
2
3
4
5
int main(){
    char str[] = "C is the best language!\0";
    // 此处打断点调试, 发现字符串长度为25, 但有效字符只有23个, 最后两个均为"NULL".
    return 0;
}
1
2
3
4
5
6
// 读入字符串(常用)
scanf("%s", str); // 遇到空白符(如" ""\r""\n"等)会停止读入, 不会读入空白符.
scanf(%[^\n], str); // 读到"\n"时才停止读入, 不会读入"\n", 本质为正则表达式.
fgets(str, size, stdin); // 读到"\n"时停止读入, 但会读入"\n".
char *ch = str; while((*ch = getchar()) != '\n') ++ch; *ch = '\0'; // 手动逐字符读入.
gets(str); // 不安全的函数, 已被C11取消支持, NOJ禁用.
1
2
3
4
// 输出字符串(常用)
fputs(str, stdout); // 输出直到遇到"\0".
printf("%s", str); // 输出直到遇到"\0".
puts(str); //不推荐的函数, NOJ偶尔无法使用.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 字符操作函数(常用)
#include <ctype.h>
int isalnum(int c) // 数字或字母
int isalpha(int c) // 字母
int isprint(int c) // 可打印函数
int islower(int c) // 小写字母
int isupper(int c) // 大写字母
int isxdigit(int c) // 十六进制数字
int tolower(int c) // 转换为小写字母
int toupper(int c) // 转换为大写字母
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 字符串操作函数(常用)
// 为避免内存泄漏和野指针问题, 通常只能对开在栈上字符串使用, 而不能对开在堆上的字符串使用
# include <string.h>
// 失败时均返回NULL
void *memset(void *str, int c, size_t n); // 复制字符c到str的前n个字符.
// 使用memset对数组初始化时, 字符c只能为0或-1或0x3f.
void *memcpy(void *dest, const void *src, size_t n); // 从src复制n个字符到dest, 一般不用于src和dest内存重叠的情况.
void *memmove(void *dest, const void *src, size_t n); // 从src复制n个字符到dest, src和dest内存重叠时推荐使用.
size_t strlen(const char *str); // 返回str长度, 即头指针到'\0'的距离(但不包含'\0').
char *strcat(char *dest, const char *src); // 将src追加到dest结尾.
char *strchr(const char *str, int c); // 返回str中第一次出现字符c的位置.
char *strstr(const char *haystack, const char *needle); // 返回在haystack中第一次出现needle的起始位置.
size_t strspn(const char *str1, const char *str2); // str1中第一个不在str2出现的字符的下标
char *strtok(char *str, const char *delim); // 以delim为分隔, 分解str.

未定义行为

C 标准中没有定义的实现, 造成的后果是难以预料的.
使用未定义行为时, 由于优化编译器可能并不会发出警告; 目前没有可靠的办法很好检查代码中是否使用了未定义行为; 根本原因在于 C 并非一种安全的语言; 在编写过程中应当避免使用未定义行为.

主要的未定义行为如下: 使用未初始化的变量; 有符号整型溢出; 过大的位移量; 解引用野指针或数组越界访问; 解引用空指针; 违反类型规则; 违反求值顺序规则.

参考: Chris Lattner, What Every C Programmer Should Know About Undefined Behavior.

待更新内容: 聚合体与字节序, 按位运算妙用, 不安全函数, 宏函数, 变长参数, 线程

Licensed under CC BY-NC-SA 4.0
最后更新于 2023-09-20
comments powered by Disqus
Built with Hugo
主题 StackJimmy 设计