【C】用C部分实现Java类 - ArrayList<E>(指针 & 函数回调 & 通配符)

概况

最近看到一道题目,就是用C实现如下功能:
在这里插入图片描述
相当用自己写一个教学管理平台,实现学生信息的插入、删除、排序功能。如果这题用面向对象的语言(比如Java 和 Python)的话,这题非常简单了。以Java为例,主要用ArrayList类就可以实现插入和删除操作,选择排序和快速排序网上的代码也很多,实现起来也不难,最多再封装一个学生类,来存储学生信息就行了。

但是如果现在用C来实现,没有面向对象的特性,也没有封装好的内置函数库。需要自己来实现上面Java已经提供的功能,那么难度就比较高了。

所以趁这个机会,给大家说说这题所涉及的相关知识。因此本篇文章将会主要讲解如何用C部分实现Java类 - ArrayList< E >1,涉及的主要知识有:

  1. 内存模型,指针;
  2. 数组;
  3. 内存分配和回收;
  4. 函数指针和函数回调;
  5. 通配符(模拟);

上述知识点将会结合项目中的内容来讲解,方便大家理解整个项目的知识点和代码。

※ 本文之中如有错误和不准确的地方,欢迎大家指正哒~
※ 此项目仅用于学习交流,请不要用于任何形式的商用用途,谢谢呢;

第一章:内存模型和指针

在计算机里面数据都是存储在内存之中的,而且整个内存可以看成一块连续的分成不同区域的区域。每个内存区域有一个唯一的编码,也就是我们说的内存地址,一般是用16进制表示。大家可以想象每个内存块就是一个房间,地址就是每个房间的房号。访客可以通过房号(地址)找到对应的房间(内存块),然后进入到房间里面(访问内存块存储的内容)。

假设我们执行下面这条语句:

int num = 10;

那么对应的内存模型如下图所示,假设每个内存块的偏移量为4,undefined表示值未知:
在这里插入图片描述

也就是说有一块地址为0x004的内存块存储了10的值,我们为这个地址起了一个别名num来表示它,下次使用的时候就不直接使用地址而是使用这个别名,这样更加方便。

沿用上面房间的例子,每个房间(内存块)除了门牌号(地址),还有对应的别名(变量名),或者花名,比如杜鹃、玫瑰。比起门牌号,花名更加方便记忆和使用。

接下来我们看看赋值操作:

> int numBig = 12;
> num = numBig;

在这里插入图片描述

那指针又是什么呢?通俗来说,指针就是指代地址的变量。上面的变量,无论是num还是numBig都是指代一个具体的值,但是指针不是,它指代的是某个地址。

在C/C++里面,某个类型的指针这样表示:类型名*,比如 int*;

语法:
类型名*

获取某个变量的地址用&运算符:&变量名,比如&num;

语法:
&变量名

比如下面这个例子:

int* numPtr = &num;

在这里插入图片描述

所以指针通俗意义来说,就是存储某个同类型变量地址的特殊变量(可以指向不同类型的指针,但是会有问题,后面章节会提到);因而,对于一个指针变量,用其他变量对其直接赋值是没有什么意义的,比如:

int* numPtr = num;

但是这个操作是合法的,编译器会有警告;

第二章:数组

理解了上面的内存模型和指针,那么数组的概念就非常好理解了。对于单个变量而言,一个变量一般只占用一个内存块。但是如果我们需要一个连续的内存区域,里面包含多个内存块呢?这个时候就需要提到数组了。

数组一般来说是就是指代一个连续的内存区域,而其数组变量名就是指代第一个内存块的地址,数组的长度决定了该数组后续占用的内存区域。

语法:
类型名 变量名[ 数组长度 ];

注意这里数组长度必须提供,且非负,比如:

int nums[3]; // 数值未初始化

这里用图来说明比较好理解:

在这里插入图片描述

从上图可知,数组nums指代其实就是从地址(0x008)到地址(0x010)这一块连续的内存区域,而其变量名nums就是指代第一个内存块的地址,即0x008;

然后我们需要访问某个下标的数组元素,只需要提供其下标就可以访问。程序会根据你提供的下标和数组首个地址来计算某个下标元素的地址;

语法:
数组变量名[ 下标 ];

比如说nums[1],计算机会先计算下标为1的元素的地址:0x008 + 内存偏移量(4) * 下标(1) = 0x00C,然后通过这个地址取到对应的元素。

通过上面的图片,其实我们还可以知道三件事情:1)为什么数组的下标应该从0开始,而不是1;2)为什么数据长度必须在初始化的时候提供;3)数组相关操作的时间复杂度;

首先来回答第一个问题,答案其实很简单,因为数组的第一个元素就数组变量所指代的地址,所以偏移量应该为:0 * 内存偏移量,第二个元素的地址偏移量才是:1 * 内存偏移量。

至于第二个问题,假设在初始化的时候不提供长度,那么计算机就不知道具体需要多少个内存块,所以无法分配数组需要的内存区域,所以初始化数组的时候必须提供数组长度;

最后是数组相关操作的时间复杂度。我们先要明确数组的长度是不可变的(Unmutable),一旦你需要的数组长度超过分配的数组长度,就需要重新分配一个更长的数组,将原来数组中的元素拷贝到新的数组中。

  1. 获取(Get):O(1) - 根据之前说们说明的获取某个数组元素的方法,可以知道获取的时间复杂都是常数级别;
  2. 添加(Add): O(n) - 最坏情况比如插入第一个位置,数组中的所有元素都需要向后整体移动一位,所以复杂度是O(n);
  3. 删除(Delete):O(n) - 和添加同理,删除第一个元素,后面的所有元素向前整体移动一位;
  4. 查找(Find):O(n) - 对于无序数组而言,查找某个元素是否在数组中,需要访问所有元素,所以是O(n) ;而对于有序数组,用二分法则是O( log( n ) ) ;

到这里,题目中关于顺序查找、删除和插入的时间复杂度分析也出来了,都是:O(n) ,如果是有序数组,二分查找则是O( log( n ) )

第三章:内存分配和回收

一般来说,在创建一个变量的时候,C的编译器会自动会当前变量分配对应所需要的内存,如果变量被销毁,则会自动释放掉对应的内存,比如上面所列举的变量,编译器会自动分配对应内存,当函数或程序结束的时候,会自动回收内存。

但是在很多情况下,这种机制会造成一些问题,需要程序员自己手动分配不会被编译器自动回收的内存,分配的内存在使用完毕后,由程序员自己手动回收内存。也就是如果这些手动分配的内存不回收,会一直不会被再利用,直到程序结束,由操作系统来进行回收

大家可以想象一个程序在执行的时候,会不断的分配内存,但是不会回收之前分配的内存,那么这个程序所占用的内存会越来越大,导致计算机变卡,或者导致爆内存的情况出现。所以良好的内存管理也是非常的重要,也能体现程序员的编程能力。

现在我们来看看一个经典的例子,交换两个数字的值:

void swap( int num1, int num2 ) {
    int temp = num1;
    num1 = num2;
    num2 = temp;
}

int main( void ) {
    int num1 = 1;
    int num2 = 2;
    swap( num1, num2 );
    printf( "%d - %d", num1, num2 ); // 1 - 2, not 2 - 1
    return 0;
}

上面的代码是不会交换在main函数的里面num1和num2的值的,至于原因,我们先来看看上面代码的内存模型:
在这里插入图片描述

在C里面,函数的参数都是按值传递,也就是函数会拷贝一份原来传进来的参数,用新拷贝的数据来执行函数体里面的代码,如上图所示;num1和num2分别有两份拷贝,分别是main里面的,和swap里面的;

如果直接上面的代码,结果是:

在这里插入图片描述

我们可以看到,main中的num1和num2的值压根没有被修改,被修改的值只是swap里面的,所以当我们在main里面打印num1和num2的时候,结果依然是1 - 2,而不是2 - 1。如果我们需要让swap函数正常的交换传进来的两个参数,那我们需要怎么做呢?这个时候,就需要传递指针,而不是需要交换的值:

void swap( int* numPtr1, int* numPtr2 ) {
    int temp = *numPtr1;
    *numPtr1 = *numPtr2;
    *numPtr2 = temp;
}

int main( void ) {
    int num1 = 1;
    int num2 = 2;
    swap( &num1, &num2 );
    printf( "%d - %d", num1, num2 ); // 2 - 1
    return 0;
}

在这里插入图片描述

对于一个指针变量,我们需要获取其指代的值,则需要进行取值操作:

语法:
*指针变量

对应的内存模型:

在这里插入图片描述

上面我们知道了函数参数传递的规则和注意事项,也知道了每个函数的变量都是独立于其他函数的,需要使用指针才能修改其他函数的变量值。那么假设有下面这种场景,我们在函数A中创建了一个变量a,然后我们需要在A之外使用a所指代的值,那么似乎可以这么来写:

int* getInteger() {
    int num = 10;
    return &num;
}

int main( void ) {
    printf("%d", *getInteger() );
}

在这里插入图片描述
执行上面的代码,结果也是10,岂不是皆大欢喜?其实这样是有问题,因为我们知道,在getInteger分配的变量num在这个函数返回以后,会被释放,等待其他程序覆盖里面的值。但是为什么打印出来的值还是10呢?这是因为虽然num所指代的内存块被释放,但是里面的值没有被覆盖,还是保留原来的值,也就是10。等到后面程序再次利用这块区域,那么里面的值会被覆盖,那么在main函数里面得到的指针所指代的内容将会变成undefined,造成程序潜在的错误,而且非常地不好debug,所以我们需要自己手动分配内存,然后手动回收。

内存分配:

语法:
( 需要获取内存的类型名* ) malloc( sizeof( 数据类型 ) );

malloc函数原型2
在这里插入图片描述

执行分配内存的代码,会得到指向该内存块的指针(void*),我们需要强转成我们需要的指针类型,比如:

int* nums = (int*) malloc( sizeof( int ) * 3 );

上面代码表示获取大小位三个内存块的内存区域,每个内存块大小为int所表示的大小。通过这种方法所获取的内存不会被程序回收,只有程序员手动回收,或者程序结束后由操作系统回收;

内存回收:

语法:
free ( 需要释放的内存指针 );

free( nums );

所以这里推荐大家在编写程序的时候,注意一下哪些地方是手动分配的,注意一下后面要手动回收,养成回收内存的好习惯。

第四章: 函数指针和函数回调

与指向变量地址的指针变量类似,函数有也指针,我们一般称之为函数指针。对于函数指针,大家可以把函数在内存中的存储结构想象成一块连续的内存区域,我们用指针变量指向这块表示函数体的地址。这样我们通过直接调用函数指针的方式,等价调用指针指向的函数。

语法:
返回类型 (*函数指针名)(参数列表)

比如项目里面定义的这个函数:

int compareToInteger( Integer* num1, Integer* num2 ) {
    return num1->num - num2->num;
}

我们可以用函数指针的方式来调用该函数,而不是一般调用方式,即compareToInteger( num1, num2 );

int main()
{
    printf("%d", compareToInteger( newInteger( 1 ), newInteger( 2 ) ) );

    // equivalent to
    // function pointer
    int (*compareToIntegerPtr)( Integer*, Integer* ) = compareToInteger; 
    printf("\n%d", compareToIntegerPtr( newInteger( 1 ), newInteger( 2 ) ) );

    return 0;
}

执行结果:

在这里插入图片描述
而函数回调就是将函数指针当作函数参数传递,通过这个特性,我们可以大幅精简代码,比如项目里面有一个线性查找功,伪代码为:

遍历数组里面的每个元素(element):
----> 如果element == target:
--------> return true;

return false;

如果有多个类型需要比较,那么我们就需要写多个查找函数,但是我们发现,伪代码里面只有判断相等的条件不一样,其余都是一样的,所以我们可以将这个比较函数用函数指针当作参数传递,也就是用到函数回调。

假设我们有下面这些类型:

// class Definition
typedef struct Integer {
    int num;
} Integer;

// class Definition Grade
typedef struct Grade {
    ArrayListGeneric* courses; // char**
    ArrayListGeneric* scores;  // double**
    ArrayListGeneric* credits; // double**
} Grade;

// class Definition Student
typedef struct Student {
    size_t ID;
    char* name;
    char* major;
    char* className;
    double weightedGPA;
    Grade myGrade;
} Student;

// class definition
typedef struct Double {
    double num;
} Double;

上述结构对应的判断相等的函数:

int equalsInteger( Integer* num1, Integer* num2 ) {
    return compareToInteger( num1, num2 ) == 0;
}

int equalsStudent( Student* aStudent1, Student* aStudent2 ) {
    return aStudent1->ID == aStudent2->ID;
}

int equalsDouble( Double* num1, Double* num2 ) {
    return compareToDouble( num1, num2 ) == 0;
}

我们发现上面三个函数的返回类型都是int,参数都为两个指针,但是指针类型不同,这里我们可以使用通用指针类型void*,表示任何一种类型的指针,在实际调用的时候,强转成对应类型的指针。

根据这一思路,我们可以写出使用函数回调的线性查找函数:

// find operations

/*
 * contains​(Object o)
 * Returns index greater than -1 if this list contains the specified element.
 *
 */

int linearSearchGeneric( ArrayListGeneric* aArrayListGeneric, void* target,
                        int (*equalsGeneric)(void*, void*) ) {
    for ( int i = 0; i < aArrayListGeneric->index; i++ ) {
        if ( equalsGeneric( aArrayListGeneric->data[i], target ) )
            return i;
    }

    return -1;
}

int (*equalsGeneric)(void *, void *) 即为作为参数传递的函数指针。

第五章:通配符(模拟)

题目中需要存储学生类型,对学生成绩/科目/学分三个项目进行查找、插入和删除操作,我们可以写三个个存储这三个类型的动态数组,而且每种动态数组的方法也要查新写一遍,非常的麻烦。那么我们能不能写一个存储任一类型的动态数组呢?相等于模拟Java里面的泛型?

答案当然是可以哒,这里我们需要回到之前提到的通用指针类型 - void*,它表示任意类型的指针,运用这一特性,我们可以使用它的二级指针 - void**来表示一个任意类型的数组,来达到模拟泛型的目的。这里为了方便使用,我们把基本类型(int,double,float等等)也封装成结构体,对应Java里面基本类型的包装类:Integer, Double, Float;

所以我们就可以写出模拟ArraList< E >的结构体:

// class Definition
typedef struct ArrayListGeneric {
    void** data;
    size_t index;
    size_t capacity;
} ArrayListGeneric;

这里稍微说一下,index表示当前数组长度,也表示新元素插入的下标;capacity表示当前动态数组的容量,算法会根据实际存储的数据长度来扩大/缩小数组所占的内存大小。

那么为什么可以使用void* 来模拟泛型呢?因为在同一个计算机中,每种类型的指针都是相同的大小,所以可以具体的指针类型可以和void* 来进行相互转换,而不会影响程序的结果

看到这里,或许有童鞋要问了:既然有void*表示通用指针类型,那么void能不能表示通用类型呢?答案是不可以的,void不能表示通用类型。而且每种基本类型所占的内存也是不一样的,如果能用void来表示通用类型,那么计算机根本无法判断为void分配多少内存空间。但是注意sizeof( void )是合法的,因为void被认为定义大小为1,但是因为上述原因,这种用法是基本没有意义的。

第六章:项目代码

对应项目代码请见:TeachingPlatform - Version 2.1

写在最后

如果大家有任何问题或疑问,都可以留言告诉我哒。特别是针对项目代码的某个地方有不明代的,可以告诉我,我会将对应的讲解添加到这篇文章里面,或者另起一篇文章讲解,比如项目里面快速排序是怎么实现的,思路什么哒


在这里插入图片描述


  1. Class ArrayList< E > ↩︎

  2. C 内存管理 ↩︎

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值