Day 7

2 数组作为参数传递

在C语言中,允许形参的类型是一个数组,也就是说在调用函数时可以传入一个数组。但C语言是值传递的,难道要将整个数组复制一份传递给函数吗?这当然不太现实,实际上:

**当数组名作为参数传递时,它会退化成指向数组首元素的指针。**比如:

  1. 将一个int类型数组作为参数传递给函数时,传递给函数的,是该数组首元素的int* 类型指针
  2. 将一个double类型数组作为参数传递给函数时,传递给函数的,是该数组首元素的double* 类型指针

因此在C语言中,当函数需要数组作为参数时,我们有两种常见的格式:

  1. 格式一,直接使用数组类型作为形参类型,(一维数组)不需要指定长度。比如:
 void func(int arr[]) {
    //...
}

此代码就表示func函数调用时,需要传入一个int类型数组。

但传入数组本质上就是传入首元素的指针,所以此函数也允许传入一个int* 类型的指针变量。

  1. 格式二,使用数组元素对应的指针类型作为形参类型。例如:
 void func(int *p) {
    //...
}

这一段代码看起来表示func函数需要传入一个int*类型的指针,也就是一个int类型变量的地址。但由于数组名本身会退化为指向首元素的指针,所以我们依然可以传入一个int类型数组。

也就是说,下面这段代码完全是可以正常运行的:

 int main(void) {
  int a = 100;
  int* p = &a;

  func(p);
  int arr[3] = { 1,2,3 };
  func(arr);

  return 0;
}

总结:

两种方式的共同点:

  1. 在函数调用时,两种格式都允许传入数组或指针。
  2. 在数组传入函数后,函数内部得到的都是一个指针。

区别在于:

  1. 第一种方式在语义上看起来更直观,因为它明确表明“我需要一个数组作为输入”。
  2. 第二种方式可能更符合C程序员操作指针的习惯,指明了数组参数传递的本质。

在以后的学习工作中,具体用什么可以视实际情况而定,而其实它们也没什么太大的区别。

作为参数传递时,数组名会退化为首元素的指针,为什么叫退化呢?

之所叫退化,而不是进化,主要原因是:

  1. 信息丢失:**数组在传递时失去了其数组长度的信息。**例如,如果你有一个大小为10的数组,当它被传递到函数时,函数不再知道它的长度。
  2. 语义变化:在数组没有传入函数时,它代表整个数组。但在函数内部,该名称只是一个指针,只代表数组的第一个元素。这是一个从"更丰富"到"更简单"的转变,所以用“退化”这个词来描述是恰当的。

总之,我们讲数组“退化”为指针,主要是指在传递数组时丢失了其原有属性,而只保留了最基本的,即首元素的地址。

2.1 数组长度无法传递

将数组作为参数传递给函数,实际传递的是指向数组首元素的指针。那么我们需要思考两个问题:

  1. 函数知道此数组的长度吗?
  2. 在函数体当中,可以利用sizeof运算符来计算数组的长度吗?

这两个问题的答案显然都是否定的

  1. 当你将数组作为参数传递给函数时,函数只得到了首元素的指针,函数没有得到任何关于数组的其他信息,包括长度。(退化)
  2. 数组作为参数传递时会退化为指针,所以sizeof运算符实际上会返回指针变量的大小,而不是数组的大小。在许多平台上,例如32位系统,指针大小为4字节;在64位系统上,指针大小为8字节。

若想要在函数当中操作数组的元素,数组长度显然是必然需要知道的数据。那怎么办呢?

所以在定义需要数组作为参数的函数时,普遍还需要一个参数用于表示数组长度。例如:

// 形参len表示调用函数时需要传入此数组的长度
int func(int a[], int len) {
  // ....
}

2.2 使用指针在函数中操作数组元素

数组作为参数传递给函数,函数实际上得到的只是一个首元素指针的副本。但即便如此,也完全不影响——我们仍然可以在函数当中直接使用数组索引语法来访问操作数组元素。

比如一个用于求和的函数,代码如下:

int sum_arr(int a[], int len) {
  int  sum = 0;
  for (int i = 0; i < len; i++) {
    sum += a[i];
  }
  return sum;
}

int main(void) {
  int arr[3] = { 1,2,3 };
  printf("sum arr = %d", sum_arr(arr, 3));
}

运行程序,结果是:

sum arr = 6

另一个值得注意的细节是:

**数组作为参数传递给函数,在函数体内部是可以修改数组元素的。**原因很简单:虽然值传递得到的只是指针的拷贝,但拷贝指针仍然指向原本的数组。

比如下列代码:

void clear(int a[], int len) {
  for (int i = 0; i < len; i++) {
    a[i] = 0;
  }
}

int main(void) {
  int arr[] = { 4, 2, 9, 7, 5, 0, -3, 8, 10, 8, 9, 10 };

  // 全部元素置为0
  clear(arr, ARR_SIZE(arr));
  // 前5个元素置为0
  clear(arr, 5);

  return 0;
}

当传入的len是数组的长度时,clear函数可以将一个int数组的所有元素全部置为0。

但实际上只要不传入大于数组长度的数(导致数组越界,引发未定义行为),这个长度可以任意传入,这样就可以灵活实现不同的功能。

这也体现了C语言当中,数组作为参数传递时的灵活性。

C语言为什么要将数组传递时退化为指针?

数组退化为指针传递在C语言中具有以下好处:

  1. 传递效率:传递一个指针到函数中比复制整个数组内容到函数要快得多。这样无论数组有多大,传递给函数的总是一个固定大小的指针。
  2. 空间效率:如果你每次都复制整个数组传递到函数,这会消耗大量的内存。但传递指针只需要传递一个很小的变量。
  3. 修改原始数据:当数组退化为指针并被传递到函数时,函数可以通过这个指针来修改原始数据,而不仅仅是其副本。这为利用函数操作数组提供了灵活性。
  4. 灵活性:数组的类型是包含数组长度的,比如arr[1]arr[3]类型就不同。但退化为指针后,函数可以接受不同大小的数组传入。(int arr[5]包括长度在内都是数组的类型,而退化为指针无需考虑数组大小)

当然,这样的设计也带有一些局限性,如不能直接在函数内部获取数组的大小,比如指针操作带来了一定的风险。但总体来说,它为C语言提供了很大的便利,利大于弊。

3 指针算术运算和数组

在C语言中,当我们有一个指针变量时,可以对其执行一些基本的算术运算来修改指针的值,从而使其指向不同的内存地址。

指针算术运算的存在,让我们能够灵活地在内存地址间移动,这使得我们可以通过指针,高效的处理各种数据结构,如数组、链表等。

指针常见的,能够进行的算术运算包括:

  1. 指针加整数。
  2. 指针减整数。
  3. 指针自增和自减(本质还是加减整数)
  4. 两个指针相减。
  5. 指针比较。

**指针算术运算并不仅仅是为数组设计的,但在实际应用中,指针算术运算最经常与数组结合使用。**所以在这里,我们以数组为载体,来讲一下指针的算术运算。

以下所有讲解都基于以下声明:

// 定义一个数组,此数组索引和元素取值一致
int arr[10] = { 0,1,2,3,4,5,6,7,8,9 };
int* p = NULL, * q = NULL;

指针可以指向数组的元素,比如:

p = arr;  // 等价于 p = &arr[0]; 
q = &arr[1];

这样通过指针p就可以实现对数组元素的操作。

3.1 指针加整数

当一个指针加上一个整数值时,表示将该指针存储的地址,增加该整数与指针指向的数据类型大小的乘积的字节数。

若存在指针p:

p = &arr[1];

现在指针p指向的是数组下标为1的元素,那么下列代码:

p += 3;

表示将p指针中存储的,下标为1的元素的地址,加上 3 * 4 = 12个字节,也就相当于arr[1] --> arr[4]

在这里插入图片描述

如果指针p指向数组元素 a[i],那么"p += j" ,意味着指针p将指向 a[i+j]。

实际上,当你学习这个知识点后,我们就可以揭开取索引运算符[]的神秘面纱:

索引运算符"[]"的原理

当你理解指针算术运算中加上整数的原理后,那么运算符"[]"的原理,就也可以一并解释了。

当我们使用索引运算符"[]"来访问数组的某个元素时,此时的数组名被视为数组首元素的指针,且该指针无法改变指向。(上面讲过)

当编译器编译到取索引运算符的代码时,编译器会将"[]"运算符还原成指针算术运算和解引用运算,也就是说:

索引运算符"[]"实际就是指针算术运算和解引用的语法糖,它的存在为程序员隐藏了底层的指针操作,让程序员能够更方便快捷地操作数组。

比如下列代码:

int arr[5] = { 1,2,3,4,5 };
printf("%d\n", arr[2]);
/*

* arr[2]完全等价于*(arr + 2) 
* 于是:
* arr[2] = *(首地址 + 2 * sizeof(int)) 
* = *(下标为2的元素指针) 
* = 下标为2元素的取值
  */ 
  printf("%d\n", *(arr + 2));

在上面数组传参的讲解中,我们直接在函数体当中用"退化为指针的数组名"进行了索引运算,如:

int sum_arr(int a[], int len) {
  int  sum = 0;
  for (int i = 0; i < len; i++) {
    sum += a[i];
  }
  return sum;
}
// 上面的代码,完全等价于
int sum_arr(int a[], int len) {
  int  sum = 0;
  for (int i = 0; i < len; i++) {
    sum += *(a + i); // 虽然可以,但实际没必要这么写
  }
  return sum;
}

3.3 指针减整数

当一个指针减去一个整数值时,表示将该指针存储的地址,减去该整数与指针指向的数据类型大小的乘积的字节数。

若存在指针p:

p = &arr[4];

现在,指针p指向的是数组下标为4的元素,那么下列代码:

p -= 3;

表示将p指针中存储的,下标为4的元素的地址,减去 3 * 4 = 12个字节,也就相当于arr[4] --> arr[1]

在这里插入图片描述

如果指针p指向数组元素 a[i],那么"p -= j" ,意味着指针p将指向 a[i-j]。

3.4 指针自增和自减

使用 ++ 或 – 实际就是指针自增整数1或自减整数1。

如果是在数组操作中,自增可以使指针指向下一个元素,自减则可以使指针指向前一个元素。

如下列代码:

p = &arr[1];
p++;    // 指向数组下标为2的元素
p--;    // 返回指向下标为1的元素

注意:由于自增自减运算符存在前后缀形式、主副作用,比较复杂,如无特殊需求尽量还是单独成语句,不要弄得太复杂。

3.5 两个指针相减

两个指针相减,会返回的是两个指针之间的元素数差,而不是实际的字节差。

下面有两个指针:

p = &arr[5];
q = &arr[1];
int i;
i = p - q;  /* i = 4  */
i = q - p;  /* i = -4 */

3.6 指针比较

在C语言中,指针之间可以进行各种比较运算。包括两类:

  1. 判等运算( ==和!= )
  2. 关系运算(<、 <=、 >、 >=)

这些比较,都是基于指针所存储的地址来实现的。

判等运算( ==和!= )

判等运算很简单,就是判断两个指针是否指向同一个地址。

比如:

p = &arr[1];
q = &arr[1];
p == q;   // true
int a = 10;
q = &a;
p == q;   // false

判等还常用于指针判NULL,比如:

if(p != NULL){
// ....
}
关系运算(<、 <=、 >、 >=)

关系运算是比较什么呢?

其实比较的就是指针中存储的地址值的大小。如果p1指向的地址,小于p2指向的地址,则p1 < p2为真。

一般而言,这个比较是没太大意义的。但是在使用数组的上下文中,是比较有用的:

因为数组元素在连续的内存地址中存储。所以,如果两个指针都指向同一个数组的不同元素,可以使用关系运算来确定哪个指针指向的元素在数组中的位置更前。

比如:

// p和q两个指针指向同一个数组中的不同元素
if (p > q) {
  printf("p指向的元素在q指向的元素后面\n");
}
else {
  printf("p指向的元素在q指向的元素前面\n");
}

3.7 指针算术运算的限制和注意事项

指针算术在C语言中是一个强大且有用的特性,但使用时也需要非常小心,因为不恰当的操作可能导致未定义的行为、程序崩溃或其他不可预测的结果。

以下是指针算术运算的一些限制和注意事项:

  1. 两个指针之间可以做减法,但不能做加法,指针也不能做乘除运算。
  2. 进行算术运算前,要确保指针不是野指针。(要将指针进行正确的初始化)
  3. 如果不确信指针是否为NULL,最好先判NULL。
  4. 只有指向同一个数组或连续的内存块的两个指针才可以被相减。
  5. 不同类型的指针不要做算术运算。
  6. 对数组的指针进行算术操作时,应确保操作后的指针仍然指向数组的元素,一旦超出数组的内存界限,即产生野指针。

4. 利用指针来处理数组

在以往的代码中,我们普遍还是将数组名结合索引运算符"[]"一起来操作数组元素。那么现在,我们可以直接利用直接来处理数组,这样写出来的代码会更加具有C语言风格。

4.1 传递数组片段

既然将数组传递给函数,函数只不过是得到了一个首元素的指针。那么能不能传其余元素的指针呢?

当然是可以的,配合传递长度,我们可以实现向函数传递一个"数组片段"。

比如下列代码:

#include <stdio.h>
#define SIZE_ARR(arr) (sizeof(arr) / sizeof(arr[0]))

// 以[1, 2, 3...]格式打印数组
void print_arr(int arr[], int len) {
  printf("[");
  for (int i = 0; i < len; i++) {
    if (i == len - 1) {
      printf("%d]\n", arr[i]);
      return;
    }
    printf("%d, ", arr[i]);
  }
}
int main(void) {
  int arr[10] = { 0, 1, 2, 3, 4, 5 , 6 ,7 ,8, 9 };

// 打印整个arr数组
  print_arr(arr, SIZE_ARR(arr));

// 从下标为3的元素开始,打印后面6个元素
  print_arr(arr + 3, 6);
  return 0;
}

请注意,在这个方法中,你只是传递了一个指针和一个长度,而不是真正传递了一个数组片段。

这意味着函数内对数组的任何修改都将影响到原始数组。如果你想创建一个真正的子数组或片段,你需要新建一个数组。

4.2 利用指针遍历数组

给定一个数组,我们可以直接利用指针来遍历数组中的元素,参考下列代码:

// 其中的len代表数组的长度
int sum = 0;
for (int *i = &arr[0]; i < &arr[len]; i++){
  sum += *i;
}

这个代码中需要注意的是:

arr[len]这个元素是不存在的,对它取地址后如果用指针变量接收,你将得到一个野指针。

但好在这里只是取地址运算,没有进行解引用,并且实际上也无法访问到这块内存区域,所以代码整体是合法的。

当然,我们已经知道数组名可以直接作为首元素指针使用,那么结合指针的算术运算,这个遍历还可以更简化:

// 其中的len代表数组的长度
int sum = 0;
for (int *i = arr; i < arr + len; i++){
  sum += *i;
}

这两个例子本质都是一样的:使用指针 i 从数组开始的位置遍历到结束的位置,和使用索引运算符没什么差别。

稍微要注意的是,在循环内部,由于变量i已经变成了一个指针类型,所以要使用解引用运算符"*"

注意:

利用指针遍历数组为程序员提供了一种直接与内存交互的方式,这在某些情况下可以提高效率或简化代码。但使用时需要特别注意避免越界等常见问题。

比如下列代码,在使用指针时就犯了逻辑上的错误,你能找到吗?

for (int *i = arr; *i < &arr[5]; *i++){
  sum += *i;
}

for (int *i = arr; i <= arr + len; i++){
  sum += i;
}

4.3 解引用运算符和自增自减结合

解引用运算符和自增自减组合是C语言中关于指针操作的一个重要话题。它们常常在一起使用,尤其是在操作数组或字符串时。

当你把解引用运算符和自增自减相结合使用时,一个非常重要的问题就是运算符的优先级以及结合性。

你需要知道以下前置的知识点:

  1. 自增自减的运算优先级是高于解引用运算的。
  2. 自增运算符和自减运算符分为前缀形式 (++p) 和后缀形式 (p++)两种,它们的副作用都是使得变量自增自减1,但主要作用不同:
    1. 前缀形式的自增和自减的主要作用是将自增自减后的结果返回。
    2. 后缀形式的自增和自减的主要作用是直接返回变量的取值。
  3. 解引用运算符的结合性是从右到左的。
  4. 自增自减前缀形式"++p"的结合性是从右到左的。
  5. 自增自减后缀形式"p++"的结合性是从左到右的。

我们先来分析一个简单的、也是最常见的组合——“*p++”。

int arr[5] = { 1,2,3,4,5 };
int* p = arr;
int num = *p++;
printf("%d\n", num);

这段代码:

  1. p指针开始时,指向数组的首元素。
  2. 表达式"*p++"中,后缀自增运算符优先级更高,"p++"先运算,它被包含在表达式中是主要作用发挥作用,也就是返回p的值。副作用会将指针p加1,也就是会将它移动到数组下一个元素的位置。
  3. 所以*p++表达式的主要作用是返回*p的值。
  4. 最后执行赋值运算符"=“,于是*p的值通过”="赋值给变量num,所以变量num的值就是数组第一个元素的值。
  5. 上述代码会在控制台输出一个"1"。

在所有解引用运算符和自增自减结合的情况当中,"*p++"是最常见,最常用的。如果你觉得这个语法实在晦涩,那么你暂时就只记住这一个就够了。

常见组合举例

除此之外,还有一些其它常见的场景,我们用一张表格来描述。

表达式含义分析
*p++ 或 *(p++)返回p当前指向位置的值,然后p移动到下一个位置上面已经分析过了
*++p 或 *(++p)p先移动到下一个位置,然后返回新位置的值前缀自增的优先级更高,于是先指针p自增,然后解引用,返回的是指针p自增后位置的值
++*p 或 ++(*p)*p先自增,然后返回自增后的值,指针位置不变前缀自增的优先级更高并且它的结合性从右到左,于是先自增整个*p的值,然后返回自增后的值。指针位置不变
(*p)++返回p,然后将p的值自增先计算p并将值返回,随后p自增

以下代码示例参考:

int arr[3] = {10, 20, 30};
int *p = arr; 

printf("%d\n", *p++); // 输出10,然后p指向20
printf("%d\n", (*p)++); // 输出20,然后arr[1]变为21
printf("%d\n", *++p); // 输出30,因为p先移动到30,然后解引用
printf("%d\n", ++*p); // 输出31,因为增加了p指向的值
如何看待这种语法?

对于C程序员,特别是初学者或那些没有经常使用指针操作的程序员来说,这种语法可能会显得有些晦涩和复杂,可能会让你觉得手足无措。

但实际上,理解这个语法能够极大的帮助你理解C语言的运算符以及指针,所以它还是很有学习的必要的。

为什么存在这种“晦涩”的语法?

  1. 效率:直接操作指针,尤其是在遍历数组和字符串时,往往比使用索引更加高效。因为这避免了多余的索引计算。
  2. 简洁性:对于熟悉此语法的程序员,这种写法使代码更加简洁,而简洁始终是C语言最重要的设计哲学之一。

但是,这并不意味着你必须使用这样的语法。其是在现代编程中,为了代码的清晰和可读性,很多公司的规范都建议不要使用容易引起混淆的复杂指针操作。(牺牲效率和简洁,提高可读性)

那么,何时考虑使用这种语法呢?总得来说,建议以下场景:

  1. 系统编程,底层编程。
  2. 性能关键的代码。
  3. 字符串操作和数组遍历。

尤其是字符串操作和数组遍历操作,强烈建议大家记住这两个操作的代码,以及**“*p++”**这种形式。

利用自增自减结合解引用运算符来遍历数组,代码示例:

// 遍历打印数组元素
void print_arr(int arr[], int len) {
  int* p = arr;
  printf("数组元素:");
  // 每循环一次打印一次元素并且刚好指针p移动一个位置
  while (p < arr + len) {
    printf("%d ", *p++);
  }
  printf("\n");
}

// 遍历求和数组
int sum_arr(int arr[], int len) {
  int* p = arr;
  int sum = 0;
  // 每循环一次累加一次元素并且刚好指针p移动一个位置
  while (p < arr + len) {
    sum += *p++;
  }
  return sum;
}

这个代码要比我们以往写的数组遍历要简洁非常多,而且相比较于索引操作,它的性能可能也会好一点。

当然它在可读性上比较差,也容易出错,具体选择什么可以根据自身情况而定。字符串相关的操作我们将在下一章节讨论。

5. 指针作为返回值

在 C 语言中,函数的返回值不允许返回一个数组,但我们可以返回一个指针作为其返回值。

数组名在大多数情况下被视为首元素指针,于是我们可以利用返回数组某元素的指针,间接实现将数组当作返回值。

将指针作为函数返回值是一个"高危操作",请务必注意:

永远不要返回指向当前栈帧区域的指针。

栈区数据会随着函数调用结束销毁,于是被返回的指针就指向了一片随机的未知区域。像这样:

曾经指向一片有效的内存区域,但后来这片区域被释放或销毁了,且仍未改变指向的指针,我们称之为"悬空指针(dangling point)"。

悬空指针最常见的场景,就是通过函数返回当前栈区内存区域的指针。

比如下列函数定义就是不允许的:

int* return_stack_num(void) {
  int num = 10;

  return &num;  // 随着函数调用结束,num变量就销毁了。不能返回给外界使用
}

int* return_stack_arr(void) {
  int arr[3] = { 1,2,3 };

  return arr;
}

**可以返回指向静态存储期限变量的指针。**因为它们在整个程序的运行期间都有效。

比如下列代码:

int* return_static_arr(void) {
 static int arr[5] = {1, 2, 3, 4, 5};
 return arr;
}

可以返回参数传递进来的指针。

int* find_middle(int a[], int n) {
 return &a[n/2];
}
int* return_ptr(int *p){
  return p;
}

总的来说,虽然返回指针在 C 语言中是一个有用的工具,但也需要小心处理,以避免常见的错误和潜在的问题。

在后续的课程中,我们还会用到指针作为返回值。

野指针和悬空指针

总结一下野指针、空指针以及悬空指针:

  1. **空指针(NULL Pointer):**空指针是指针变量的一个特殊取值,它表示指针未指向任何内存区域。
    1. 空指针为指针类型提供了一种安全的不可用标记,任何对空指针的操作都会导致程序崩溃,而不是未定义行为。
    2. 当声明一个指针变量时,如果暂时没有确切的地址要指向,就可以初始化为一个空指针。这样就可以避免因未初始化指针,而出现野指针。
    3. 建议在不确定指针是否为空指针的情况下,解引用指针先进行判NULL处理。
  2. **野指针(Wild Pointer):**在C语言中,任何指向随机未知非法的内存区域的指针都叫野指针。
    1. 一个局部变量指针变量,若没有手动初始化,那么它就会指向一片随机未知的内存区域,这就是一个典型的野指针。
    2. 任何对野指针的操作都会导致未定义行为,可能导致程序崩溃,也可能导致程序计算出奇怪的、莫名其妙的结果。这是非常坑的。
    3. 避免野指针是C程序员指针操作永远需要注意的,这既需要程序员的细致耐心,也考验程序员的经验。
  3. **悬空指针(Dangling point):**在C语言中,悬空指针专指那些"曾经指向有效内存区域,但由于内存被释放销毁而没有改变指向,从而指向非法内存区域的指针"。
    1. 悬空指针是一种特殊的野指针,悬空指针一定是野指针。但野指针不仅仅包括悬空指针。
    2. 最常见的悬空指针就是返回当前栈区内存区域的指针。
    3. 使用悬空指针操作内存,同样会引发未定义行为,要像规避野指针一样规避悬空指针。

在最后,我们提几个规避野指针的建议:

  1. 永远不要返回指向当前栈帧区域的指针。
  2. 在一片内存区域被free释放后,养成好习惯,立刻将它设置为空指针。
  3. 在声明指针时,应该立即初始化它们。如果暂时没有合适的地址可以指向,应该初始化为空指针。
  4. 细致耐心的检查自己手动申请管理的内存区域,随时追踪该片内存区域的状态,在适当的时候进行free或设置空指针等操作。

以上。

  • 21
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

如是我闻艺

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值