函数 数组 和 指针我们三这一块


😠 函数

7.1 函数定义

  • 函数的定义就是函数体的实现:
  • 函数体就是一个代码块,它在函数被调用时执行:
shell
类型
函数名( 形式参数 )
代码块

function_name()
{

}

存根(stub):为那些此时尚未实现的代码保留一个位置。

K&R C形参声明

shell
int *
find_int(key, array, array_len)
int key;
int array[];
int array_len;
{}

return语句

  • return语句允许从函数体的任何位置返回。
  • return expression中的expression是可选的。
  • 通常,表达式的类型就是函数声明的返回类型。

没有返回值或return;的函数类型应该设置为void函数。

函数被分为有返回值的函数(真函数)和没有返回值的函数(过程或副作用)。

7.2 函数声明

  • 使用 函数原型(function prototype) 为编译器提供函数的完整信息:
  • 对于K&R C风格函数编译器只知道函数返回值类型:

标准表示,在同一个代码块中,函数原型必须与同一个函数任何先前原型匹配。

7.2.2 函数的缺省认定

  • 无法见到原型的函数,编译器认为该函数返回值为整数类型。

7.3 函数的参数

  • C函数的所有参数均以 "传值调用" 方式进行传递,即传递参数的副本。
  • 传递指针和数组的行为实际上为 "传址调用"

对指针和数组进行间接访问操作

缺省参数提升:使用K&R C旧风格的形参类型类似char 和 short类型会被提升为int类型,float类型会被提升为double类型

7.4 ADT和黑盒

  • C语言可以用于设计和实现抽象数据类型(ADT, Abstract Data Type),因为它可以限制函数和数据定义的作用域。

  • 抽象数据类型(ADT, Abstract Data Type) 是一种编程概念,它将数据的表示(如何存储)和对数据的操作(如何使用)分离开来。

ADT的核心思想

  1. 数据隐藏(Data Hiding):数据的具体存储方式被隐藏起来,外部代码无法直接访问或修改。

  2. 接口(Interface):ADT提供一组明确定义的公共函数,作为与外部世界的唯一交互方式。

  • 限制对模块的访问是通过合理使用static关键字来实现的,它可以限制对那些并非接口的函数和数据的访问。

示例:一个简单的计数器模块

counter.h(公共接口)

这是模块的”对外合同“,它只声明了其他文件可以访问的公共函数。

shell
#ifndef COUNTER_H
#define COUNTER_H

// Increment the counter by 1.
void increment_counter(void);

// Get the current value of the counter. 
int get_counter(void);
#endif // COUNTER_H

counter.c(黑盒实现)

这是模块的”内部黑盒“。static关键字使得count变量和private_helper函数无法被其他文件直接访问。

shell
#include "counter.h"
#include <stdio.h>
// This is a static variable. Its value is kept for the lifetime of the program,
// but it is only visible and accessible within this file (counter.c).
static int count = 0;

// This is a static function. It's a private helper and cannot be called
// from outside this file.
static void private_helper()
{
  printf("--- (Internal) The counter is now being updated... ---\n");
}

// Public API function. It calls the private helper function
void increment_counter()
{
  private_helper(); // This is allowed bacause private_helper is in the same file.
  count++;
}

// Public API function. It returns the value of the private variable.
int get_counter()
{
  return count;
}

main.c(使用模块)

这个文件只能通过counter.h中声明的公共函数与counter.c交互。任何试图访问内部私有函数的行为都将导致编译或链接错误。

shell
#include <stdio.h>
#include "counter.h"

int main()
{
  printf("Initial counter value: %d\n",get_counter());
  
  // Call the public function to increment the counter.
  increment_counter();
  printf("Counter value after first increment: %d\n", get_counter());

  increment_counter();
  printf("Counter value after second increment: %d\n", get_counter());

  // COMPILE/LINK ERROR!
  // The linker will fail because private_helper() is not a public symbol.
  // private_helper(); 

  return 0; 
}

如果不包含最后一行private_helper()的调用程序将编译和链接成功。

7.5 递归

  • 递归函数就是直接或间接调用自身的函数。
  • C通过运行时堆栈支持递归函数的实现。
  • 递归的效率大部分情况下都非常低。

使用递归与迭代来表示阶乘的计算

shell
factorial(n) =
├── n <= 0 : 1
└── n > 0 : n x factorial(n - 1)

递归方法

shell
long 
factorial(int n)
{
  if (n <= 0)
    return 1;
  else 
    return n * factorial(n - 1);
}

迭代方法

shell
long
factorial(int n)
{
  int result = 1;
  while (n > 1)
  { 
    result *= n;
    n -= 1;
  }
  
  return result;
}

许多问题是以递归的形式进行解释的,这只是因为它比非递归形式更为清晰,但是这些问题的迭代实现往往比递归实现效率更高。

使用递归与迭代来表示斐波那契数

shell
Fibonacci(n) =
├── n <= 1 : 1 
├── n = 2 : 1
├── n > 2 : Fibonacci(n - 1) +Fibonacci(n - 2)

递归方法

shell
long fibonacci(int n)
{
  if (n <= 2)
    return 1;

  return fibonacci(n - 1) + fibonacci(n - 2);
}

迭代方法

shell
long fibonacci(int n)
{
  long result;
  long previous_result;
  long next_older_result;
  
  result = previout_result = 1;
  
  if (n <= 2)
    return 1;
  
  while (n > 2)
  {
    n -= 1;
    next_older_result = previous_result;
    previous_result = result;
    result = previous_result + next_older_result;
  }
}

使用递归实现斐波那契数的代价比你远想的要大。

7.6 可变参数列表

  • 使用stdarg宏实现可变参数列表,这些宏定义在stdarg.h头文件
  • 在定义可变参数列表时在省略号前一定是代表后面可变参数列表参数个数

这个头文件声明了一个类型va_list和3个宏---va_startva_argva_end

计算标量参数平均值(非可变参数)

shell
float average(int n_values, int v1,int v2, int v3, int v4, int v5)
{
  float sum = v1; 
  
  if ( n_values >= 2)
    sum += v2;
  if ( n_values >= 3 )
    sum += v3;
  if ( n_values >= 4 )
    sum += v4;
  if ( n_values >= 5 )
    sum += v5;

  return sum / n_values;
}

计算标量参数平均值(可变参数)

shell
#include <stdarg.h>

float average(int n_values, ...) // 传递任意数量的未知数
{
  va_list var_arg;
  int count;
  float sum;
  
  // 准备访问可变参数
  va_start (var_arg, n_values);
  
  // 添加取自可变参数列表的值
  for (count = 0;count < n_values;count += 1)
    sum += va_arg(var_arg, int);
  
  // 完成处理可变参数
  va_end(var_arg);
  
  return sum / n_values;
}

🖐 数组

8.1 一维数组

  • 指针与数组并不是相等的。

8.1.1 数组名

考虑下面的这些声明:

shell
int a;
int b[10];

a为标量,因为它是一个单一的值;b为数组,因为它是一些值的集合。

数组: 相同类型的值的集合。

int b[10]中的b并不表示整个数组而是表示数组首个元素的地址。数组为int类型表示数组名的类型为"指向int的常量指针"。

  • 区分指针与数组差别的其中一个理由是数组名是指针常量而不是指针变量

常量的值是不能修改的,这也就意味着如果有一个指针int *c那么b = c这种赋值是非法的,因为b是常量。

8.1.2 下标引用

  • 除优先级外,下标引用和间接访问完全相同。
shell
array[subscript] == *(array + (subscript))

证明相等性:

shell
int array[10];
int *ap = array + 2;
statementexpression
aparray + 2 and &array[2]
*aparray[2] and *(array + 2)
ap[0]*(ap + 0) and array[2]
ap+6array + 8 and &array[8]
*ap+6array[2] + 6
*(ap+6)array[8]
ap[6]*(ap+6)
&apunpredictable
ap[-1]correct operation and its array[1]
ap[9]unpredictable

偏移量的负数是可以允许的

最后两个例子显示了为什么下标检查在C中是一项困难的任务。最初的C编译器并不检查下标,而最新的编译器有些依然不检查下标。且如果编译器进行下标检查涉及的开销比想象的多。

2[array] 是一个合法的数组表示!它表示的是 (2 + array),也就是(array + 2)即array[2]。

8.1.3 指针与下标

  • 如果可以互换地使用指针表达式和下标表达式,下标绝不会比指针更有效率,指针表达式有时候比下标表达式更有效率

下标方案执行循环

shell
int array[10],a;
for (a = 0;a < 10;a++)
  array[a] = 0;

指针间接访问方案执行循环

shell
int array[10],*ap;
for (ap = array;ap < array + 10;ap++)
  *ap = 0;
  • 在比较老的编译器中指针间接访问比下标访问更有效率

1. 数组下标版本
对于array[a] = 0;这行代码,计算机在每次循环时都必须做以下三件事:
a. 找到数组的起始地址(array);
b. 将循环变量a乘以int类型的大小(例如四字节);
c. 将相乘的结果加到起始地址,才能找到array[a]的准确内存位置。

这个过程在每次循环中都涉及一次乘法和一次加法

2. 指针间接访问版本
对于*ap = 0;这行代码,计算机的处理方法更直接:
a. 指针ap已经直接存储了当前要操作的内存地址。
b. *ap操作直接访问地址,非常快。
c. ap++操作只需要简单地在ap的地址上加上int类型的大小(例如四字节),就能得到下一个元素的地址。

这个过程只涉及一次简单的加法,比乘法要快得多。

  • 但现代编译器非常智能,像数组下标版本会被优化成更高效的指针算术指令

8.1.4 指针的效率

  • 程序的效率取决于你的编译器机器

把一个数组的内容复制到另一个数组:

shell
#define SIZE 50
int x[SIZE];
int y[SIZE];
int i;
int *p1,*p2;

下标版本函数:

shell
void try1()
{
  for(i = 0; i < SIZE;i++)
    x[i] = y[i];
}

指针版本函数:

shell
void try2()
{
    for( p1 = x, p2 = y; p1 - x < SIZE;)
      *p1++ = *p2++;
}

重新使用计数器:

shell
void try3()
{
  for( i = 0; p1 = x, p2 = y; i < SIZE; i++)
  {
    *p1++ = *p2++;
  }
}

寄存器指针变量:

shell
void try4()
{
  register int *p1, *p2;
  register int i;
  
  for( i = 0;p1 = x, p2 = y;i < SIZE; i++)
    *p1++ = *p2++;
}

消除计数器:

shell
void try5()
{
  register int *p1, *p2;

  for( p1 = x, p2 = y;p1 < &x[SIZE])
  {
    *p1++ = *p2++;
  }
}

重新使用计数器是一个比较不错的写法,而消除计数器则是一个更加快速的执行代码。

结论:

  1. 使用指针变量将比使用下标产生效率更高的代码。当这个增量是1并且机器具有地址自动增量模型时,这点表现得更为突出。
  2. 如果有经过初始化并经过调整的内容来判断循环是否应该终止,就不需要使用一个单独的计数器。
  3. 那些必须在运行时求值的表达式诸如&array[SIZE]array+SIZE这样的常量表达式往往代价更高。

8.1.5 数组和指针

  • 指针和数组并不是相等的。

在使用int a[10]int *b这两个指针值时表达式*a是合法而*b是非法的;表达式b++可以通过编译,但a++不行。

8.1.6 作为函数参数的数组名

shell
void strcpy(char *buffer, char const *string)
{
  while (*buffer++ = *string++ != '\0');
}

while语句中的*string++表达式取得string所指向的那个字符,并且产生一个副作用,就是修改string,使它指向下一个字符。用这种方法修改形参并不会影响调用程序的实参,因为只有传递给函数的那份拷贝进行了修改。

8.1.7 声明数组参数

int strlen(char * string)int strlen(char string[])这两种声明是相等的,但是只在当前这个上下文环境中

  • 使用char *string指针声明数组参数更加准确,且数组作为形参不需要传递元素个数,因为函数并不为数组参数分配内存空间,形参只是一个指针

8.1.8 初始化

int vector[5] = {10,20,30,40,50};是一个标准的数组初始化。

静态和自动初始化

  • 数组初始化的方式类似于标量变量的初始化方式---也就是取决于它们的存储类型。
  • 存储在静态内存的数组只初始化一次,也就是在程序开执行之前。
  • 对于自动变量而言自动变量位于运行时堆栈编译器没办法在程序开始前对它进行初始化。所以自动变量在缺省情况下是未初始化的。

当数组的初始化局部于一个函数(或代码块)时,你应该仔细考虑一下,在程序的执行流每次进入该函数(或代码块)时,每次都对数组进行重新初始化是不是值得。如果答案是否定的,就把数组声明为static,这样数组的初始化只需在程序开始前执行一次。

8.1.9 不完整的初始化

  • 初始化值数目大于数组长度(编译时错误);小于则将未初始化的元素初始化为0,且只允许省略最后几个初始值。

8.1.10 自动计算数组长度

int vector[] = {1,2,3,4,5};让编译器识别数组大小

8.1.11 字符数组的初始化

两种初始化方法:
1.char message[] = {'H','e','l','l','o',0};
2.char message[] = "Hello";

这两种初始化方法是相同的,都是一个字符数组初始化列表,而char* message = "Hello";是一个字符串常量,即一个指向常量字符串"Hello"的指针。上面两个都是可修改的字符数组。

8.2 多维数组

  • int martix[6][10]在某些上下文环境中,既是6行10列,也是10行6列。

8.2.1 存储顺序

int array[4];是一个存储三个整型元素的数组
int array[4][6];在上面的基础上将三个整型元素改为三个包含6个元素的数组

  • 在C中,多维数组的元素存储顺序按照最右边下标率先变化的原则称为行主序(row major order)。比如读取array[2][4]往下读取应该是array[2][5]后是array[3][0]

8.2.2 数组名

  • 一维数组名的值是一个指针常量,它的类型是"指向元素类型的指针"
  • 多维数组和一维数组为一个区别是多维数组的第一维元素实际上是另一个数组

int martix[3][10];这个数组名matrix的值是一个指向它第一个元素的指针,所以matrix是一个指向一个包含10个整型的数组的指针。

8.2.3 下标

matrix: 指向包含10个整型元素的数组的指针
matrix+1: 指向包含10个整型元素的数组的指针,但是指向的是matrix的下一行
*(matrix + 1): 包含10个整型元素的子数组(常量指针),与matrix[1]相等
*(matrix + 1) + 5: 上面的常量指针右移5位
*(*(matrix + 1) + 5): 上面指针所指向的元素,与matrix[1][5]值相等
上面的式子可以改写为:*(matrix[1] + 5)

8.2.4 指向数组的指针

shell
int   vector[10], *vp = vector;
int   matrix[3][10], *mp = matrix;

第一个声明是合法的,第二个声明是非法的。
matrix不是一个指向整型的数组而是一个指向整型数组的指针。

int (*p)[10]是一个合法的指向二维数组的数组指针。因为下标引用的优先级高于间接访问 ,所以要给*p加上括号。

shell
int *pi = &matrix[0][0];
int *pi = matrix[0];

上面的两个指针指向二维数组的首地址可以逐个访问整型元素而不是逐行在数组中移动

应该避免使用int (*p)[] = matrix;这种没有数组长度的声明,因为没有声明数组长度当执行指针运算时它的值将根据空数组的长度进行调整(与0相乘)

8.2.5 作为函数参数的多维数组

如果是二维数组你可以声明为以下的任意一个

shell
void func2(int (*mat)[10]);
void func2(int mat[][10]);
  • 编译器必须知道第二个及以后各维的长度才能对各下标进行求值,因此在原型中必须声明这些维的长度,也就是在原数组声明时至少为int mat[][10]这种并且在函数形参声明时也写成这样

void func2(int **mat);是一个指向整型指针的指针,和一开始说的一样,指针和数组是不同的,和指向整型数组的指针不是一回事。

8.2.6 初始化

shell
int matrix[2][3] = {100,101,102,110,111,112};

int two_dim[3][5] = {
  {00,01,02,03,04},
  {00,01,02,03,04},
  {00,01,02,03,04}
};

上面两种初始化的方法都可以,对于三维以上的数组来说也是类似,和一维数组一样也可以省略尾部的几个初始值。

8.3 指针数组

int *api[10];: 下标引用的优先级高于间接访问,在这个表达式中首先执行下标引用。

使用指针数组的场景:

shell
char const *keyword[] = {
    "do",
    "for",
    "register",
    "return",
    "switch",
    "while",
    NULL 
};
#define N_KEYWORD \
  (sizeof(keyword) / sizeof(keyword[0]))

# 判断参数是否与一个关键字列表中的任何单词匹配,并返回匹配的索引值。
# 如果未找到匹配函数返回-1
#include <string.h>

int lookup_keyword( char const* const desired_word, 
              char const *keyword_table[], int const size)
{
  char const **kwp;
  
  // 对于表中的每个单词 ...
  // for (kwp = keyword_table; kwp < keyword_table + size; kwp++)
  // {
  //   if (strcmp(desired_word, *kwp) == 0)
  //       return kwp - keyword_table;
  // }
  for (kwp = keyword_table; kwp != NULL;kwp++)
  {
    if (strcmp(desired_word, *kwp) == 0)
      return kwp - keyword_table;
  }
  

  // 没有找到
  return -1;
}

使用sizeof()对数组元素个数进行自动计数

8.4 总结

  1. sizeof(array)返回的是整个数组所占用的字节而不是一个指针所占用的字节。
  2. &array 和 array 指向的地址相同,但是&array的类型为int (*)[]而array的类型为int*
  3. 其他使用数组名的地方数组名都是指向数组第一个元素的指针
  4. 数组地址(类似于&array[1][2]) = 数组基地址 + (行索引 * 每行大小) + (列索引 * 每行大小)

地址 = 基地址 + (行索引 * 列数 * 元素大小) + (列索引 * 元素大小)

假如int array[4][2],int大小为2,array的地址为0x1000,则 &array[1][2] = 0x1000 + (1 * 8) + (2 * 4) == 0x1016

💝 字符串、字符和字节

  • 字符串是一种重要的数据类型,但是C语言并没有显式的字符串数据类型,因为字符串以字符串常量的形式出现或者存储于字符数组中。
  • 操作字符串变量时必须额外小心各种可能导致缓冲区溢出的操作。

9.1 字符串基础

  • 字符串是一串零个或多个字符,并且以一个位模式为全0的NUL字符结尾

字符串所包含的字符内部不能出现NUL字节

9.2 字符串长度

  • 字符串的长度就是它所包含的字符个数。

使用标准库头文件#include <string.h>中的函数计算字符串长度

shell
strlen 原型
size_t strlen (char const *string);

size_t 是一个无符号整数类型,且这个类型是在头文件stddef.h中定义的

  • 无符号数的使用需要考虑是否会产生负数
shell
if (strlen(x) >= strlen(y)) ...
if (strlen(x) - strlen(y) >= 0) ...

这两个式子是不相等的,第二行的strlen(x) - strlen(y)的返回结果是一个无符号数,无符号数绝不可能是负数

strlen原型

shell
#include <stddef.h>

size_t 
strlen(char const *string)
{
  int length;
  
  for (length = 0; *string++ != '\0';)
    length += 1;
  return length;
}
  • 表达式中如果同时存在无符号数和有符号数,可能会产生奇怪的结果
shell
if (strlen(x) >= 10) ...
if (strlen(x) - 10 >= 0) ...

这两个式子也是不相等的,原因和上面相同。

如果把strlen的返回值强制转换为int,就可以消除这个问题。

tips:

自己重写一个标准库函数可能会比标准库函数效率更高,如果合理使用寄存器register声明和一些技巧,但事实上很少能如愿

寻找一种更好的算法比改良一种差劲的算法更有效率,复用已经存在的软件比重新开发一个效率更高。

9.3 不受限制的字符串函数

  • 最常用的字符串函数都是不受限制的,就是说它们只是通过寻找字符串参数结尾的NUL字节来判断它的长度。

9.3.1 复制字符串

shell
char *strcpy(char *dst, char const *src);

这个函数把参数src字符串复制到dst参数,由于dst参数将进行修改,所以它必须是个字符数组或者是一个指向动态分配内存的数组的指针,不能使用字符串常量

程序员必须保证目标字符数组的空间足以容纳需要复制的字符串。因为如果字符串比数组长,多余的字符仍然被复制,它们将覆盖原先存储于数组后面的内存空间的值。

strcpy无法解决这个问题,因为它无法判断目标字符数组的长度。

9.3.2 连接字符串

shell
char *strcat( char *dst, char const *src);
  • 找到字符串末尾NUL并将src中的首字符覆盖掉NUL

常见strcat用法

shell
strcpy( message, "Hello ");
strcat( message, customer_name );
strcpy( message, ", how are you?");

9.3.3 函数的返回值

  • strcpystrcat都返回它第一个参数的一份拷贝

将函数返回值作为另一个函数的参数

shell
strcat(strcpy(dst,a),b);

首先执行strcpy将字符串从a复制到dst并返回dst。然后这个返回值成为strcat函数的第一个参数,strcat函数把b添加到dst的后面。

但是在可读性上其实不如

shell
strcpy(dst,a);
strcat(dst,b);
  • 事实上,在这些函数的绝大多数调用中它们的返回值只是被简单地忽略。

9.3.4 字符串比较

  • 比较两个字符串涉及对两个字符串对应的字符逐个进行比较,直到发现不匹配为止。
  • 那个最先不匹配的字符中较"小"(字符集中序数较小)的那个字符所在的字符串被认为"小于"另外一个字符串
shell
int strcmp(char const *s1, char const *s2);

如果s1小于s2,strcmp函数返回一个小于零的值,反之返回一个大于零的值。相等返回零。

shell
if (strcmp(a,b)) // 用于布尔值测试是一种坏风格
if (strcmp(a,b) > 0) // 用于与零进行比较更好
else if (strcmp(a,b) < 0)
else

9.4 长度受限的字符串函数

标准库还包含了一些函数,它们以一种不同的方式处理字符串。

shell
char *strncpy(char *dst, char const *src, size_t len);
char *strncat(char *dst, char const *src, size_t len);
char *strncmp(char const *s1, char const *s2, size_t len);

这些函数接受一个显式的长度参数,用于限定进行复制或比较的字符数。

  • 注意⚠️:如果strlen(src)的值大于或等于len,那么只有len个字符被复制到dst中。它的结果将不会以NUL字节结尾。

strncpy 调用的结果可能不是一个字符串,因此字符串必须以NUL字节结尾。

保证strncpy的结果是以'\0'结尾的

shell
char buffer[BSIZE];
...
strncpy(buffer, name, BSIZE);
buffer[BSIZE - 1] = '\0';

如果name的内容可以容纳于buffer中最后的赋值语句无效。

如果strlen(name) >= BSIZE 那么最后一条赋值语句可以截断name的字符防止缓存区溢出。

strncat总是在结果字符串后面添加一个NUL字节,所以不会有这种问题;strncmp只比较len长度的字符串是否相等,如果strlen(name)比BSIZE大则只比较BSIZE长度个字符。

9.5 字符串查找基础

9.5.1 查找一个字符

shell
char *strchr(char const *str, int ch);
char *strrchr(char const *str, int ch);

虽然ch是int类型但是包含一个字符值。

  • strchr在字符串str中查找字符ch第一次出现的位置,找到后函数返回一个指向该位置的指针,如果该字符不存在则返回NULL指针。strrchr返回一个指向字符串中最后一次出现的位置。
shell
char string[20] = "Hello there, honey.";
char *ans;
ans = strchr(string, 'h');

9.5.2 查找任何几个字符

shell
char *strpbrk(char const *str, char const *group);
  • 返回一个指向str中第一个匹配group中任何一个字符的字符位置。如果未找到匹配则返回NULL指针。区分大小写。
shell
char string[20] = "Hello there, honey.";
char *ans;
ans = strchr(string, "aeiou");

ans所指向的位置是string + 1,因为这个位置是第二个参数中的字符第一次出现的位置。区分大小写。

9.5.3 查找一个子串

shell
char *strstr(char const *s1, char const *s2);
  • 在s1中查找s2第一次出现的位置,并返回一个指向该位置的指针;如果s2并没有完出现在s1返回一个NULL指针;如果第二个参数是一个空指针函数返回s1。

自己实现一个 strtstrstrrpbrk 函数

shell
#include <string.h>

char*
my_strrstr(char const *s1, char const *s2)
{
  register char *last;
  register char *current;

  // 把指针初始化为我们已经找到的前一次匹配
  last = NULL;
  
  // 只在第二个字符串不为空的时候进行查找,如果s2为空返回NULL
  
  if (*s2 != '\0')
  {
    // 查找 s2 s1 中第一次出现的位置。
    current = strstr(s1,s2);
    while (current != NULL)
    {
      last = current;
      current = strstr(last + 1, s2);
    }
  }
  return last; // 返回指向我们找到的最后一次匹配的起始位置的指针。
}
shell
#include <string.h>

char *
my_strrpbrk(char const *str, char const *group)
{
  register char *last;
  register char current;
  
  // 把指针初始化为我们已经找到的前一次匹配
  last = NULL;
  
  if (*s2 != '\0')
  {
    current = strpbrk(str, group);
    while (current != NULL)
    {
      last = current;
      current = strpbrk(last + 1, group);
    }
  }
  return last;
}

9.6 高级字符串查找

9.6.1 查找一个字符串前缀

  • strspnstrcspn函数用于计算字符串开头连续匹配指定字符集中的字符的个数
shell
size_t strspn(char const *str, char const *group);
size_t strcspn(char const *str, char const *group);

str: 要检查的字符串。
group:包含要匹配的字符集的字符串。

strspnstr1 的第一个字符开始,依次检查每个字符。只要这个字符在 str2 中能找到,就继续向后检查。一旦遇到一个不在 str2 中的字符,函数就停止并返回已匹配的字符数。

strcspnstr1 的第一个字符开始,依次检查每个字符。只要这个字符不在 str2 中,就继续向后检查。一旦遇到一个在 str2 中的字符,函数就停止并返回已检查的字符数。

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

int main() {
    const char *sentence = "Hello, world!";
    const char *charset_vowels = "aeiou";
    const char *charset_alpha = "abcdefghijklmnopqrstuvwxyz";

    size_t length1 = strspn(sentence, "Heo");
    printf("The length of the initial part of \"%s\" consisting of 'H', 'e', 'o' is: %zu\n", sentence, length1); 
    // 输出: 2 (因为 'l' 不在 "Heo")

    size_t length2 = strspn(sentence, charset_alpha);
    printf("The length of the initial part of \"%s\" consisting of letters is: %zu\n", sentence, length2);
    // 输出: 5 (因为 ' ' 不在字母表中)

    return 0;
}

9.6.2 查找标记

  • strtok函数用于分割字符串。它会根据指定的分隔符,将字符串分解成一系列的标记(token)。
shell
char *strtok(char *str, char const *sep);

str:要被分割的字符串。 sep:包含一个或多个分隔符的字符串。

strtok是在字符串本身进行处理的(in-place),建议使用原字符串的拷贝进行操作。

  • strtok的使用非常特殊,因为它是有状态的:
    • 第一次调用:传入要分割的字符串str。它会找到第一个分隔符,用\0替换它,并返回第一个标记的指针。
    • 后续调用:传入NULL作为str参数。strtok会从上次停止的位置继续,找到下一个分隔符,用\0替换它,并返回下一个标记的指针。
shell
#include <stdio.h>
#include <string.h>

int main(void)
{
  char str[] = "apple,banana-orange";
  const char *delimiters = ",-";
  char *token;

  // 第一次调用, 传入字符串
  token = strtok(str,delimiters);
  printf("First token: %s\n", token);
  
  // 后续调用,传入NULL
  while(token != NULL)
  {
    token = strtok(NULL,delimiters);
    if (token != NULL){
      printf("Next token: %s\n",token);
    }
  }
  return 0;
}

9.7 错误信息

  • 当你调用一些函数,请求操作系统执行一些功能如打开文件时,如果出现错误,操作系统是通过设置一个外部的整型变量errno进行错误代码报告的。strerror函数把其中一个错误代码作为参数并返回一个指向用于描述错误的字符串的指针。
shell
char *stderror(int error_number);

事实上,返回值应该被声明为const,因为你不应该修改它。

  • strerror函数用于将错误编号转换为人类可读的错误信息字符串。
shell
#include <stdio.h>
#include <string.h>
#include <errno.h>
// 必须包含此头文件来使用 errno

int main(void)
{
  FILE *file;
  // 尝试打开一个不存在的文件
  file = fopen("non_existent_file.txt", "r");
  
  // 如果 fopen 失败
  if (file == NULL)
    printf("Error opening file: %s\n", strerror(errno));
  else
  {
    printf("File opened successfully.\n");
    fclose(file);
  }
  return 0;
}

9.8 字符操作

  • 字符操作的原型位于ctype.h头文件中,分为字符分类函数和字符转换函数

9.8.1 字符分类

字符分类函数

函数如果它的参数符合下列条件就返回真
isspace空白字符: '空格', 换页'\f',换行 '\n', 回车 '\r', 制表符 '\t'或垂直制表符'\v'
isdigit十进制数字0~9
isxdigit十六进制数字,包括所有十进制数字,小写字母a~f,大写字母A~F
islower小写字母a~z
isupper大写字母A~Z
isalpha字母a~z A~Z
isalnum字母或数字,a~z,A~z或0~9
ispunct标点符号,任何不属于数字或字母的图形字符(可打印符号)
isgraph任何图形字符
isprint任何可打印字符,包括图形字符和空白字符

9.8.2 字符转换

shell
int tolower(int ch);
int toupper(int ch);

if (ch >= 'A' && ch <= 'Z') 在ASCII字符集的机器上能够运行,但是其他字符集有可能会失败
if (isupper(ch))
就都能顺利运行

9.9 内存操作

  • 根据定义,字符串由一个NUL字节结尾,所以字符串内部不能包含任何NUL字符。但是非字符串数据内部包含零值的情况并不罕见。你无法使用字符串函数来处理这种类型的数据,因为当它们遇到第一个NUL字节时将停止工作。
  • 使用内存操作函数可以处理任意的字节序列
shell
void *memcpy(void *dst, void const *src, size_t length);
void *memmove(void *dst, void const *src, size_t length);
void *memcmp(void const *a, void const *b, size_t length);
void *memchr(void const *a, int ch, size_t length);
void *memset(void *a, int ch, size_t length);
  • 和字符操作函数类似,对于memcpy()函数,如果src与dst以任何形式出现了重叠,它的结果是未定义的。但是memmove()函数可以重叠。
  • 内存操作函数传入的长度为内存长度不是元素长度。
  • 任何类型的指针都可以转换为void*型指针。

memcpy

memcpy copies a block of memory from a source to a destination location.

Parameters:

  • dest: A pointer to the destination memory block.
  • src: A pointer to the source memory block.
  • n: The number of bytes to copy

Use case: Copying data between non_overlapping memory regions.

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

int main(void)
{
  int source_array[] = {1,2,3,4,5};
  int destinaion_array[5];
  
  // copy 5 integers from soure_array to destination_array
  memcpy(destination_array, source_array, sizeof(source_array));

  printf("Destinaion array: ");
  for (int i = 0; i < 5; i++)
  {
    printf("%d ",destination_array[i]);
  }
  printf("\n");
  return 0;
}

memmove

memmove copies a block of memory from a source location to a destination location, even if the memory blocks overlap.

Parameters: Same as memcpy

Use case: Shifting data within the same array or buffer. This is safer than memcpy for overlapping regions.

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

int main(void)
{
  char str[] = "abcdefgh";
  
  // Shift the string two characters to the left
  // The source and destination overlap (str + 2 and str)
  memmove(str, str + 2,strlen(str) - 2);
  
  // manually add the null terminator since memmove doesn't
  str[strlen(str) - 2] = '\0';
  printf("String after memmove: %s\n", str); // prints "cdefgh"

  return 0;

}

memchr

memchr search a block of memory for the first occurrence of a specific byte.

Parameters:

  • a: A pointer to the memory block to search
  • ch: The byte value to search for. It's passed as an int but is treated as an unsigned char.
  • length: The number of bytes to search.

Return Value:

  • A pointer to the first occurrence of the byte ch within the first n bytes of the memory block a.
  • A null pointer (NULL) if the byte is not found.

Use Case: Searching for a specific byte within a block of raw binary data.Unlike string functions like strchr. memchr continues its search past null terminators (\0).

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

int main(void) {
  // This array contains a null byte in the middle.
  char data[] = {'h', 'e', 'l', 'l', 'o', '\0', 'w', 'o', 'r', 'l', 'd'};
  char *result;
- 
  // Search for the character 'o' within the first 11 bytes of the array.
  // A string search function like strchr would stop at the '\0'
  result = memchr(data, 'o', sizeof(data));
  if (result != NULL) {
    printf("Found 'o' at memory address: %p\n", result);
    printf("Character found: '%c'\n", *result);

    // Calculate the index of the found character
    size_t index = (size_t)(result - data);
    printf("It is located at index: %zu\n", index);
  } else {
    printf("The character was not found.\n");
  }
  return 0;
}

memcmp

memcmp compares a specified number of bytes in two memory blocks

Parameter:

  • a: A pointer to the first memory block
  • b: A pointer to the second memory block
  • length: The number of bytes to compare.

Use case: Comparing raw binary data, which may contain null bytes that would terminate string functions like strcmp.

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

int main(void)
{
  char data1[] = "Hello";
  char data2[] = "HellO";
  char data3[] = "Hello";
  
  // compare the first 5 bytes of data1 and data2
  int result1 = memcmp(data1, data2, 5);
  if (result1 != 0)
  {
    printf("data1 and data2 are different.\n");
  }
  // compare the first 5 bytes of data1 and data2
  
  int result2 = memcmp(data1, data3, 5);
  if(result2 != 0)
  {
    printf("data1 and data3 are different.\n");
  }
  return 0;
}

memset

memset fills a block of memory with a specified byte value.

Parameters:

  • a: A pointer to the memory block to fill.
  • ch: The value to be set. It's passed as an int but is converted to an unsigned char.
  • length: The number of bytes to fill.

Use case: Initializing a block of memory, such as an array or a structure, to all zeros or a specific value.

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

int main(void)
{
  char buffer[10];
  
  // Initialize all 10 bytes of the buffer to 'A'
  memset(buffer, 'A', sizeof(buffer));
  printf("Buffer after memset: %.10s\n",buffer);
  
  // Initialize the buffer to all zeros (a common and safe practice)
  memset(buffer, 0, sizeof(buffer));
  printf("Buffer after zeroing: %d\n", buffer[0]); // Prints 0

  return 0;
}

9.10 总结

  • 字符串就是零个或多个字符的序列,该序列以一个NUL字节结尾。

9.13 问题

  1. C语言缺少显式字符串数据类型,这是一个优点还是一个缺点?

Answer:

C语言缺少显式的字符串数据类型,既是它的优点,也是它的缺点

优点:灵活性和高效性

C语言的字符串被实现为以 空字符(\0) 结尾的字符数组,这带来了以下几个显著的优点:

  • 内存效率高:C语言的字符串存储非常紧凑,没有额外的元数据(如长度信息)。这使得C语言程序在处理大量文本时,内存开销极小。

  • 直接操作内存:字符串作为数组,可以像普通数组一样通过指针直接访问和操作每个字符。这给予了程序员极大的灵活性,可以实现各种高效的算法,例如原地修改、零拷贝(zero-copy)等。这在系统编程、嵌入式开发和性能关键的应用中至关重要。

  • 互操作性强:几乎所有编程语言都支持字节数组或指针,这使得C语言的字符串可以轻松地与其他语言(如Python、Rust、Java等)进行接口交互,而无需复杂的类型转换。

缺点:安全性和易用性

这种设计也带来了明显的缺点,尤其是在安全和易用方面:

  • 安全性风险:由于字符串的长度信息不是显式存储的,字符串处理函数(如 strcpy、strcat、sprintf)都假定目标缓冲区足够大,这极易导致缓冲区溢出。这是C语言长期以来面临的最大安全挑战之一。程序员必须手动跟踪字符串长度,否则会引发严重的漏洞。

  • 容易出错:忘记在字符串末尾添加 \0,或不小心覆盖了它,都会导致程序读取到无效内存,引发未定义行为。初学者经常会因为这些问题而感到困惑。

  • 操作不便:字符串的拼接、截取等操作不像高级语言那样简洁。例如,要拼接两个字符串,你需要手动计算所需空间、分配内存、然后使用 strcpy 和 strcat,整个过程繁琐且容易出错。