第四部分
关于C语言的新知识已经不多了,本章节将介绍宏定义、结构体共用体、程序文件层次等知识,接着将与大家一起运用我们所学知识编写具有实际功能意义的C语言程序。
宏定义与别名
宏定义
宏定义就是将一句话或一段话用一个名称替换。在编译程序时,系统并不是直接编译程序,而是先要经历预处理的过程。在预处理过程中,系统会删除例如注释或未起作用的宏定义,并且会继续宏定义替换,宏定义替换仅仅是替换,不会做任何其他事情。使用方法为:#define 宏名称 宏语句
通常的,宏定义名称都是全部大小的英文字母;例如:宏定义并使用PI
#include <stdio.h>
#define PI 3.1415926
int main()
{
printf("半径为1的圆的面积为%lf", 1 * 1 * PI);
return 0;
}
运行结果为:
半径为1的圆的面积为3.141593
宏定义仅仅是替换,不会计算或做任何其他事情。例如:
#define a 3+5
int b=a*a;
预处理后,代码就变成了
int b=3+5*3+5; //并不是8*8
宏定义的有效作用范围是从定义宏定义之后直到文件结尾。如果其他文件包含了本文件,那么它也可以使用该宏定义。宏定义可以使用undef强制结束有效期。例如:
#include <stdio.h>
#define PI 3.1415926
int main()
{
#undef PI
printf("半径为1的圆的面积为%lf", 1 * 1 * PI);
return 0;
}
此时程序处于预处理,还没有编译,所以不存在{}是一个作用域的概念。宏定义仅仅是对代码进行文本层次的操作,先定义PI宏,之后有使用undef删除了该宏,所以程序会报错。编译结果为:
带参数宏定义
使用形式:
#define 宏名(参数1名称,参数2名称) 语句
注意宏定义中参数不需要参数类型,因为系统更本管他是不是变量,仅仅是将参数名称替换,例如:求面积
#include <stdio.h>
#define S1(r) ((r)*(r)*3.1415926)
#define S2(r) r*r*3.1415926
int main()
{
printf("半径为2的圆的面积为%lf\n", S1(1.0 + 1));
printf("半径为2的圆的面积为%lf\n", S2(1.0 + 1));
return 0;
}
运行结果为:
半径为2的圆的面积为12.566370
半径为2的圆的面积为5.141593
很明显不符合我们预期,为什么会有两种不同结果?在预处理结束后程序被替换为:
...头文件stdio.h的具体内容
int main()
{
printf("半径为2的圆的面积为%lf\n", ((1.0 + 1)*(1.0 + 1)*3.1415926));
printf("半径为2的圆的面积为%lf\n", 1.0 + 1*1.0 + 1*3.1415926);
return 0;
}
这样我们就能看出来为什么两个程序会不一样。宏定义仅仅是替换,不会计算或做任何其他事情。
条件编译宏开关
条件编译宏开关与条件语句if有相似之处,他们都是先判断后执行。不同的是,if语句中如果条件为假则不执行;但是条件宏编译如果条件为假,则该部分代码在预处理阶段将直接被删除。
- 条件宏编译完整形式1:
#if 1
//代码
#elif
//代码
#else
//代码
#endif
使用示例:选择输出
#include <stdio.h>
int main()
{
#if(0)
printf("if\n");
#elif(1)
printf("else if\n");
#else
printf("else\n");
#endif
return 0;
}
在预处理之后,程序变成了
#include <stdio.h>
int main()
{
printf("else if\n");
return 0;
}
运行结果为:
else if
- 条件宏编译完整形式2:
#ifdef 宏名称
//代码
#else
//代码
#endif
//---------或者------------
#ifndef 宏名称
//代码
#else
//代码
#endif
ifdef作用是如果之前定义过该宏,则编译下列代码,否则编译else后面的代码;ifndef作用是如果之前未定义过该宏,则不用下列代码,否则编译else后面的代码。这种形式的宏定义通常用于文件复杂的包含关系,例如stdio.h头文件中形式类似于:
#ifndef __STDIO_H__
#define __STDIO_H__
//具体内容
#endif
这样有个好处就是,第一次包含时,由于没有宏定义__STDIO_H__,程序正常包含;
后续如果还有地方包含stdio.h头文件时,由于已经有__STDIO_H__宏定义了,则程序预处理时会直接跳过,并不会再次包含头文件。
类型别名
C语言支持对某一类型重新起名,就是typedef。其使用形式为:
typedef int myint; //给int类型取一个新名称myint
typedef int* pint; //给int*类型取一个新名称pint
其实宏定义也可以做到类似的作用:
#define myint int //将所有出现myint的地方用int替代
#define pint int* //将所有出现pint的地方用int*替代
但是值得注意的是,typedef是一条语句,他的作用是给某一类型起一个新的名称,而宏定义仅仅是替换,例如:
#include <stdio.h>
#define TYPE1 int*
typedef int* TYPE2;
int main()
{
TYPE1 p1, p2;
TYPE2 p3, p4;
}
TYPE2指的是一种等价于int*的类型,p3、p4都是int型指针;TYPE1是宏定义,在预处理结束后,TYPE1 p1, p2;
这条语句将变成int* p1,p2;
,很明显,p1是int指针,而p2仅仅是int类型变量。typedef常用于为结构体类型起一个新的名称。
结构体与共用体
结构体与共用体是数据打包的一种形式,也可以理解为自定义了一种新的数据类型。
在 C 语言中,结构体(struct)是一个或多个变量的集合,这些变量可能为不同的类型,为了处理的方便而将这些变量组织在一个名字之下。由于结构体将一组相关变量看作一个单元而不是各自独立的实体,因此结构体有助于组织复杂的数据,特别是在大型的程序中。共用体(union),也称为联合体,是用于(在不同时刻)保存不同类型和长度的变量,它提供了一种方式,以在单块存储区中管理不同类型的数据。
结构体
结构体定义方式如下
struct 结构体名称
{
变量1类型 变量1名称;
变量2类型 变量2名称;
...
};
例如结构体定义有以下几种形式:
//只定义一个结构体类型
struct TYPE1
{
int a;
double b;
char c[10];
};
//定义一个变量
struct TYPE1 t1; //注意struct TYPE1是类型,必须要加上struct关键字
//a是一个struct TYPE1类型变量,该变量有3个结构体成员,使用.运算符访问,分别是a.a;a.b;a.c;
//定义一个结构体类型和一个结构体变量
struct TYPE2
{
int a;
}t2;
//结构体类型struct TYPE2(类比int),结构体变量是t2(类比i)
实际使用时,每当我们需要一个结构体变量时都需要加上关键字struct,显得非常麻烦且没必要,所以我们可以使用typedef为该结构体类型取别名。例如以下几种形式:
//别名形式1
struct TYPE1
{
int a;
};
typedef struct TYPE1 mytype1; //之后mytype1等价于struct TYPE1;
//别名形式2
typedef struct
{
int a;
}mytype2; //之后mytype2就是我们想要的类型
//别名形式3
typedef struct TYPE3 //之后mytype3等价于struct TYPE3;
{
int a;
}mytype3;
学会结构体类型定义之后,我们需要定义结构体变量,有以下几种形式:
typedef struct
{
int a;
char c[20];
}mytype;
//不初始化定义
mytype t1;
//列表初始化定义
mytype t2={0,"asdf"};
我们并不能像访问变量一样方便的访问结构体成员,而是需要使用 . 运算符访问。相应的,如果是结构体指针变量,我们可以使用(*).访问或者使用->运算符。例如:
#include <stdio.h>
typedef struct
{
int a;
char c[10];
double d;
}mytype;
int main()
{
mytype a;
mytype* p = &a;
//通过变量名称访问
a.a = 1;
//通过指针访问:(*p)等价于a
(*p).c[0] = 'A';
(*p).c[1] = '\0';
//通过指针访问:->运算符
p->d = 10;
printf("结构体类型占内存:%d\n", sizeof(mytype));
printf("结构体成员a=%d,c=%s,d=%lf", a.a, a.c, a.d);
return 0;
}
运行结果为:
结构体类型占内存:24
结构体成员a=1,c=A,d=10.000000
一个结构体中含有int(4字节)、char[10](10字节)、double(8字节)共22字节,为何sizeof计算内存会有24个字节呢?下面我们将从更加底层的地址空间进行分析,也为了更好地理解结构体共用体,如果读者不是很明白也没有关系,可以看完指针专辑后再回来看看本小结内容。
之前介绍指针概念时说过,在计算机存储中,数据是线性存储,并且以字节为单位,每个字节都有对应的地址。实际上在计算机系统中,寻址是一个较为复杂的过程,为了简化运算量,计算机也会偷懒。在给变量分配地址时,系统会优先选择4的整数倍的地址,例如以上例程,结构体变量地址为:0X 0000 0062 39EF F8C8,也就是说,从0X 0000 0062 39EF F8C8到0X 0000 0062 39EF F8C8+23都是该结构体变量的内存范围,并且它依据内存对其原则。
结构体内存对其原则:
1、数据成员对齐规则:结构(struct)(或共用(union))的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员存储的起始位置要从该成员大小或者成员的子成员大小(只要该成员有子成员,比如说是数组,结构体等)的整数倍开始(比如int在32位机为4字节,则要从4的整数倍地址开始存储。
2、结构体作为成员:如果一个结构里有某些结构体成员,则结构体成员要从其内部最大元素大小的整数倍地址开始存储.(struct a里存有struct b,b里有char,int ,double等元素,那b应该从8的整数倍开始存储.)
3、收尾工作:结构体的总大小,也就是sizeof的结果,.必须是其内部最大成员的整数倍.不足的要补齐。
例如之前例程中结构体变量a:
变量名称 | 内存地址 | 存储内容 |
---|---|---|
int a:7-0bit | 0X 0000 0062 39EF F8C8 | |
int a:15-8bit | 0X 0000 0062 39EF F8C9 | |
int a:23-16bit | 0X 0000 0062 39EF F8CA | |
int a:31-24bit | 0X 0000 0062 39EF F8CB | |
char c[10]:c[0] | 0X 0000 0062 39EF F8CC | |
char c[10]:c[1] | 0X 0000 0062 39EF F8CD | |
char c[10]:c[2] | 0X 0000 0062 39EF F8CE | |
char c[10]:c[3] | 0X 0000 0062 39EF F8CF | |
char c[10]:c[4] | 0X 0000 0062 39EF F8D0 | |
char c[10]:c[5] | 0X 0000 0062 39EF F8D1 | |
char c[10]:c[6] | 0X 0000 0062 39EF F8D2 | |
char c[10]:c[7] | 0X 0000 0062 39EF F8D3 | |
char c[10]:c[8] | 0X 0000 0062 39EF F8D4 | |
char c[10]:c[9] | 0X 0000 0062 39EF F8D5 | |
占位补齐 | 0X 0000 0062 39EF F8D6 | |
占位补齐 | 0X 0000 0062 39EF F8D7 | |
double d:7-0bit | 0X 0000 0062 39EF F8D8 | |
double d:15-8bit | 0X 0000 0062 39EF F8D9 | |
double d:23-16bit | 0X 0000 0062 39EF F8DA | |
double d:31-24bit | 0X 0000 0062 39EF F8DB | |
double d:39-32bit | 0X 0000 0062 39EF F8DC | |
double d:47-40bit | 0X 0000 0062 39EF F8DD | |
double d:55-48bit | 0X 0000 0062 39EF F8DE | |
double d:63-56bit | 0X 0000 0062 39EF F8DF |
这就是为什么该结构体占24字节。
共用体
共用体又称联合体。结构体是为每个内部成员变量开辟一个内存,并存储数据;而共用体则是使用同一块内存存储所有成员变量,这样,当我们修改一个成员变量时,可能会导致其他成员变量内存被修改,实际上共用体一般只选取其中一个成员变量使用。
共用体关键字是union,类比struct,两者操作及其类似。例如:
#include <stdio.h>
typedef union
{
int a;
char c[10];
double d;
}mytype;
int main()
{
mytype a;
printf("结构体变量a大小为:%d\n", sizeof(a));
printf("结构体变量a的地址为:%p\n", &a);
printf("成员变量a的地址为:%p\n", &a.a);
printf("成员变量c的地址为:%p\n", &a.c);
printf("成员变量d的地址为:%p\n", &a.d);
return 0;
}
运行结果为:
结构体变量a大小为:16
结构体变量a的地址为:000000BC4A3EFC68
成员变量a的地址为:000000BC4A3EFC68
成员变量c的地址为:000000BC4A3EFC68
成员变量d的地址为:000000BC4A3EFC68
我们继续从地址的角度分析:
共用体内存对其原则:
1、数据成员对齐规则:结构(struct)(或共用(union))的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员存储的起始位置要从该成员大小或者成员的子成员大小(只要该成员有子成员,比如说是数组,结构体等)的整数倍开始(比如int在32位机为4字节,则要从4的整数倍地址开始存储。
2、结构体作为成员:如果一个结构里有某些结构体成员,则结构体成员要从其内部最大元素大小的整数倍地址开始存储.(struct a里存有struct b,b里有char,int ,double等元素,那b应该从8的整数倍开始存储.)
3、收尾工作:结构体的总大小,也就是sizeof的结果,.必须是其内部最大成员的整数倍.不足的要补齐。
变量名称 | 内存地址 | 存储内容 |
---|---|---|
int a:7-0bit char c[10]:c[0] double d:7-0bit | 0X 0000 0062 39EF F8C8 | |
int a:15-8bit char c[10]:c[1] double d:15-8bit | 0X 0000 0062 39EF F8C9 | |
int a:23-16bit char c[10]:c[2] double d:23-16bit | 0X 0000 0062 39EF F8CA | |
int a:31-24bit char c[10]:c[3] double d:31-24bit | 0X 0000 0062 39EF F8CB | |
char c[10]:c[4] double d:39-32bit | 0X 0000 0062 39EF F8CC | |
char c[10]:c[5] double d:47-40bit | 0X 0000 0062 39EF F8CD | |
char c[10]:c[6] double d:55-48bit | 0X 0000 0062 39EF F8CE | |
char c[10]:c[7] double d:63-56bit | 0X 0000 0062 39EF F8CF | |
char c[10]:c[8] | 0X 0000 0062 39EF F8D0 | |
char c[10]:c[9] | 0X 0000 0062 39EF F8D1 | |
占位补齐 | 0X 0000 0062 39EF F8D2 | |
占位补齐 | 0X 0000 0062 39EF F8D3 | |
占位补齐 | 0X 0000 0062 39EF F8D4 | |
占位补齐 | 0X 0000 0062 39EF F8D5 | |
占位补齐 | 0X 0000 0062 39EF F8D6 | |
占位补齐 | 0X 0000 0062 39EF F8D7 |
验证结构体共用内存:
#include <stdio.h>
typedef union
{
int a;
char c[10];
double d;
}mytype;
int main()
{
mytype a;
a.a = 10;
printf("a=%d\n", a.a);
a.c[0] = 0;
a.c[1] = 0;
a.c[2] = 0;
a.c[3] = 0;
printf("a=%d\n", a.a);
return 0;
}
运行结果为:
a=10
a=0
结构体VS共用体
结构体与共用体对比:
- 结构体是各种数据的集合封装,相对于将一些零零散散的变量集中在一起组成一个新的、更复杂的变量。也可以理解成自定义用于对某个物体的描述的变量的集合。例如:描述人的结构体
typedef struct
{
char name[16]; //姓名
char sex[16]; //性别
double height; //身高
double weight; //体重
}people;
- 共用体(联合体)更偏向于对数据内容选择一项使用,有时我们也用于数据格式转换,例如:内容选择
typedef union
{
char stu_id[16]; //学生学号
char tea_id[16]; //老师工号
}
或者例如数据转换:在我们需要无线通信时,数据都是以字节形式传输,这时候我们可以先发送高8位、再发送低8位的方法来完成数据传输,但是当数据是double或其他更大类型数据时,继续使用移位方式发送数据就显得较为麻烦,可以参考一下例程
typedef union
{
double d;
char c[8];
}mydouble;
该例程完美解决数据传输时,需要先传高位还是先传低位的问题。我们只需顺序发送char数组中内容,即可完成8字节double类型数据的传输。
分文件编译
一个大型工程会有很多不同文件组合在一起,称为模块化编程,也就是多文件编程。把不同功能的函数封装到不同的文件中。一个.c文件和一个.h文件被称为一个模块。
开发C程序时,稍微大型的项目就需要使用多文件开发(模块化编程)。当代码量较大功能较复杂时,单一文件程序会使得文件非常巨大,代码量非常大,成千上万行的代码在一个文件中不便于修改和维护,因此需要将不同的功能模块放在不同的文件中。
以往我们都是在一个文件中进行编程,调用一个主函数完成所有的事情,但很多时候我们需要写很多个文件。调用一个,让这个主程序自己去调用或者去找其他的文件运行。
C语言程序文件有源文件和头文件
头文件
头文件的作用有一下几点:
- 对其他头文件的包含:
头文件head1.h也可以包含其他头文件例如head2.h,并且任何包括了head1.h头文件的地方也包含了。head2.h。 - 常用宏的定义:
头文件中可以对宏进行定义,并且所有包含了该头文件的文件中都将可以使用该宏定义。 - 结构体类型定义:
头文件中可以对结构体类型定义,并且所有包含该头文件的文件中都可以使用该类型。 - 全局变量声明:
头文件中可以变量声明,但是不能变量定义,声明之后,所有包含该头文件的文件可以直接使用该变量,且无需定义。可以使用extern声明全局变量,变量的定义与声明不同,请看下列介绍:
//不带extern的类似下列语句的都是变量定义
int a;
double d=1.2
//带extern并且对变量初始化的语句,都是变量定义,例如
extern int a=0;
extern char c[10]="asdf";
//带extern并且不对变量初始化的语句,可能是变量定义,也可能是变量声明
extern int a;
extern float f;
//当系统在全局变量中未找到变量a时,该语句就是变量定义;当系统在全局变量中找到变量a,则该语句就是变量声明
- 函数声明:
头文件中可以声明函数,但是不可以定义函数。在头文件中声明函数后,其他包含该头文件的文件就可以使用该函数。 - 对类型取别名:
头文件可以使用typedef给类型取别名,并且所有包含该头文件的文件都可以使用别名
例如:
/*******这里是head.h头文件********/
#ifndef HEAD_H
#define HEAD_H
//包含其他头文件
#include <stdio.h>
//宏定义
#define PI 3.141592658979 //pai
#define ABS(x) ((x)<0?(-x):(x)) //绝对值
//结构体定义+取别名
typedef struct
{
int x;
int y;
}point; //点类型,包括横坐标x和纵坐标y
//函数作用:用于计算点p与原点距离
double myfun(point p); //函数声明,该函数必须要在其他地方有定义
#endif
值得注意的是,上述例程中,还有一个条件编译ifndef,只有当第一次包含该头文件时,才会没有HEAD_H的定义,于是后续都将编译;当第二次包含该头文件时,由于之前已经定义了HEAD_H,则该文件直接被整体注释了。
源文件
源文件的作用有以下几点:
- 可以使用入口函数main():
源文件可以包含主函数main(),并且程序将从main开始运行,整个项目只能有一个main函数。 - 可以包含头文件:
源文件可以包含头文件,并且可以使用头文件中声明的宏、结构体类型、类型别名、全局变量、声明的函数。 - 可以定义函数:
源文件可以自定义函数,并且可以声明函数,在定义或声明函数之后也可以使用该函数。 - 可以完成所有头文件的功能:
程序可以没有头文件,但必须有源文件,源文件可以使用所有头文件的功能,但是如果程序复杂,这样会显得很繁琐。
包含自己写的工程中的文件与包含库文件略有区别:
//包含库文件
#include <stdio.h> //编译器直接去库中寻找该文件
//包含工程文件
#include "head.h" //编译器先找工程目录下有没有该文件,若没有则去编译器库中寻找
下面给大家展示简易点运算的工程框架,方便大家理解源文件与头文件的关系。
calculator.h
/**********这里是calculator.h**********/
#ifndef CALCULATOR_H //防止重复包含
#define CALCULATOR_H
#include <stdio.h> //包含所需头文件
#include <math.h>
#define ABS(x) ((x)<0?(-(x)):(x)) //定义全局宏
typedef struct //定义点类型
{
double x;
double y;
}point;
extern point p1, p2, p3; //声明变量p1,p2,p3
//输入:点a,点b
//输出:double类型距离
//作用:计算点a,点b之间的距离
double dist_atob(point a, point b);
//输入:点a
//输出:double类型距离
//作用:计算点a与原点距离
double dist_a(point a);
#endif
calculator.c
/**********这里是calculator.c**********/
#include "calculator.h" //包含所需头文件
point p1, p2, p3; //全局变量定义
//输入:点a,点b
//输出:double类型距离
//作用:计算点a,点b之间的距离
double dist_atob(point a, point b)
{//根号下(ax-bx)^2+(ay-by)^2
return sqrt((a.x-b.x) * (a.x-b.x) + (a.y-b.y) * (a.y-b.y));
}
//输入:点a
//输出:double类型距离
//作用:计算点a与原点距离
double dist_a(point a)
{
point b;
b.x = 0;
b.y = 0;
return dist_atob(a, b); //计算a点与(0,0)点距离
}
main.c
/**********这里是main.c**********/
#include "calculator.h"
int main()
{
//可以直接使用p1、p2、p3全局变量
p1.x = 0;
p1.y = 0;
p2.x = 0;
p2.y = 3;
p3.x = 4;
p3.y = 0;
printf("p1p2距离为%lf\n", dist_atob(p1, p2));
printf("p2p3距离为%lf\n", dist_atob(p2, p3));
printf("p1p3距离为%lf\n", dist_atob(p1, p3));
}
整个工程编译运行结果为:
p1p2距离为3.000000
p2p3距离为5.000000
p1p3距离为4.000000
实战前准备
C语言学习到这就已经能完成很多任务了,我们将简单学习几个有用的函数以方便我们后续程序制作。这些函数本身并不属于C语言内容,而是属于window的API接口。它们可以简单控制或修改cmd窗口。
1、隐藏光标函数——需要windows.h
//输入:无
//输出:无
//作用:隐藏控制台光标
void hide_cursor()
{
CONSOLE_CURSOR_INFO cursor;
cursor.bVisible = FALSE;
cursor.dwSize = sizeof(cursor);
HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);
SetConsoleCursorInfo(handle, &cursor);
}
2、清屏函数——需要stdlib.h
//输入:无
//输出:无
//作用:清屏
void clear_screen()
{
system("cls");
}
3、光标跳转函数——需要windows.h
//输入:坐标xy
//输出:无
//作用:光标跳转到指定坐标,左上角为(0,0)
void goto_xy(int x, int y)
{
COORD pos; //定义光标位置的结构体变量
pos.X = x; //横坐标设置
pos.Y = y; //纵坐标设置
HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE); //获取控制台句柄
SetConsoleCursorPosition(handle, pos); //设置光标位置
}
4、修改控制台字体颜色——需要windows.h
//输入:x
//输出:无
//作用:设置屏幕颜色0-15编号
void color(short x)
{
SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE), x % 16);
}
5、进程休眠(延时)——需要windows.h
//输入:休眠时长x,单位毫秒
//输出:无
//作用:进程休眠x毫秒
void delay_ms(int x)
{
Sleep(x);
}
5、获取当前时间——需要time.h
C语言中读取系统时间的函数为time (),其函数原型为:#include time_t time ( time_t * ) ;time_t就是long,函数返回从1970年1月1日(MFC是1899年12月31日)0时0分0秒,到现在的的秒数。(使用时自行包含头文件并调用该函数,这里就不放入api.h文件中了)
我们将这些小函数封装在接口文件api.h和api.c中,方便我们调用:
api.h
#ifndef API_H
#define API_H
#include <stdlib.h>
#include <Windows.h>
//输入:无
//输出:无
//作用:隐藏控制台光标
void hide_cursor();
//输入:无
//输出:无
//作用:清屏
void clear_screen();
//输入:坐标xy
//输出:无
//作用:光标跳转到指定坐标,左上角为(0,0)
void goto_xy(int x, int y);
//输入:颜色代号x,0-15
//输出:无
//作用:设置屏幕颜色0-15编号
void color(short x);
//输入:休眠时长x,单位秒
//输出:无
//作用:进程休眠x秒
void delay_s(int x);
#endif
api.c
#include "api.h"
//输入:无
//输出:无
//作用:隐藏控制台光标
void hide_cursor()
{
CONSOLE_CURSOR_INFO cursor;
cursor.bVisible = FALSE;
cursor.dwSize = sizeof(cursor);
HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);
SetConsoleCursorInfo(handle, &cursor);
}
//输入:无
//输出:无
//作用:清屏
void clear_screen()
{
system("cls");
}
//输入:坐标xy
//输出:无
//作用:光标跳转到指定坐标,左上角为(0,0)
void goto_xy(int x, int y)
{
COORD pos; //定义光标位置的结构体变量
pos.X = x; //横坐标设置
pos.Y = y; //纵坐标设置
HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE); //获取控制台句柄
SetConsoleCursorPosition(handle, pos); //设置光标位置
}
//输入:x
//输出:无
//作用:设置屏幕颜色0-15编号
void color(short x)
{
SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE), x % 16);
}
//输入:休眠时长x,单位毫秒
//输出:无
//作用:进程休眠x毫秒
void delay_ms(int x)
{
Sleep(x);
}
现在我们可以利用这些api写个动态的、颜色变化的自动滚屏程序了!例如:滚动字幕
main.c
#include <stdio.h>
#include "api.h"
int main()
{
int i, j;
hide_cursor(); //隐藏光标
for (i = 0; i < 10; i++)
{
goto_xy(0, 0); //回到屏幕最开始的位置
color(i); //修改颜色
for (j = 0; j < i; j++) //输出i个空格
printf(" ");
printf("hello world");
delay_ms(500); //延时500ms
}
return 0;
}
运行结果为:
实战1——计算器
功能要求:键盘输入数字和加减乘除符号及等号,屏幕能正确输出结果。
提升要求:d键删除一个字符,c键归零,e退出。
参考例程:章节尾统一给出
核心讲解:
while (c=getche()) //注意使用的是等于而不是双等于,先赋值再判断该字符是否为非0数
{
switch (c)
{//由于我们是从屏幕接收字符,所以与普通数据不同,'0'与0并不一样
case '0':
case '1':
case '2':
case '3':
case '4':
case '5':
case '6':
case '7':
case '8':
case '9':
case '.':
case '+':
case '-':
case '*':
case '/':
case '=':
case 'c':
case 'd':
case 'e':
}
}
通过不断读取字符并做相应处理。
运行结果为:
实战2——万年历
功能要求:输入年份月份可以查看该月日历。
提升要求:周日要求标红,e退出
参考例程:章节尾统一给出
核心讲解:核心是以1900年1月1号周一为基准
闰年平年判断
//输入:年份
//输出:天数
//作用:返回该年天数
int day_of_year(int year)
{
if (year % 4 == 0 && year % 100 != 0)
{
return 366;
}
else if (year % 100 == 0 && year % 400 == 0)
{
return 366;
}
else
{
return 365;
}
}
该年该月有几天
//输入:年份,月份
//输出:天数
//作用:返回该年月的天数
int day_of_month(int year, int month)
{
switch (month)
{
case 1:return 31; break;
case 2:
if (day_of_year(year) == 365)
return 28;
else
return 29;
break;
case 3:return 31; break;
case 4:return 30; break;
case 5:return 31; break;
case 6:return 30; break;
case 7:return 31; break;
case 8:return 31; break;
case 9:return 30; break;
case 10:return 31; break;
case 11:return 30; break;
case 12:return 31; break;
default:printf("%d月份不存在\n", month); return 0; break;
}
}
以1900年1月1日周一为基准。平年365天,闰年366天。可以计算出每年第一天是周几
//输入:年份
//输出:周几
//作用:计算该年第一天是周几
int week_of_year(int year)
{
int i = 1900; //1900年1月1号周一
int day=0; //从1900年开始总共有多少天
while (i != year)
{
day += day_of_year(i);
i++;
}
return day % 7 + 1;
}
同理可以算出该年该月第一天是周几。再配上该年该月共有几天,即可实现该年该月的日历打印
//输入:年份,月份
//输出:无
//作用:打印该年月的日历
void print_month(int year, int month)
{
int week, day, i;
printf("---------------------------------------------------\n");
printf("* %d.%d \t*\n", year, month);
printf("一\t二\t三\t四\t五\t六\t");
color(12);
printf("日\n");
color(7);
printf("---------------------------------------------------\n");
week = week_of_month(year, month); //该年月1号为周几
day = day_of_month(year, month); //该年月有多少天
for (i = 1; i < week; i++) //空格
printf("\t");
for (i = 1; i <= day; i++) //遍历输出所有日期
{
if ((i + week - 1) % 7 == 0) //周日条件
{
color(12);
printf("%d\t", i);
color(7);
printf("\n");
}
else
{
printf("%d\t", i);
}
}
printf("\n");
printf("---------------------------------------------------\n");
}
实战3——简易扫雷(精讲)
功能要求:实现扫雷基本游戏功能,asdw控制光标上下左右移动,j点击格子,k标记/取消标记地雷。
提升要求:制作简易界面,有欢迎动画、游戏介绍、时间统计
参考例程:章节尾统一给出
核心讲解:
api.c和api.h定义了对控制台的操作及光标操作,并封装成文件,该部分只要会调用即可,涉及到window系统API接口,不属于我们重点讨论的内容。
api.h中关于控制台与光标的函数声明
#ifndef API_H
#define API_H
#include <stdlib.h>
#include <Windows.h>
//输入:无
//输出:无
//作用:隐藏控制台光标
void hide_cursor();
//输入:无
//输出:无
//作用:清屏
void clear_screen();
//输入:坐标xy
//输出:无
//作用:光标跳转到指定坐标,左上角为(0,0)
void goto_xy(int x, int y);
//输入:颜色代号x,0-15
//输出:无
//作用:设置屏幕颜色0-15编号
void color(short x);
//输入:休眠时长x,单位毫秒
//输出:无
//作用:进程休眠x毫秒
void delay_ms(int x);
#endif
api.c函数具体定义
#include "api.h"
//输入:无
//输出:无
//作用:隐藏控制台光标
void hide_cursor()
{
CONSOLE_CURSOR_INFO cursor;
cursor.bVisible = FALSE;
cursor.dwSize = sizeof(cursor);
HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);
SetConsoleCursorInfo(handle, &cursor);
}
//输入:无
//输出:无
//作用:清屏
void clear_screen()
{
system("cls");
}
//输入:坐标xy
//输出:无
//作用:光标跳转到指定坐标,左上角为(0,0)
void goto_xy(int x, int y)
{
COORD pos; //定义光标位置的结构体变量
pos.X = x; //横坐标设置
pos.Y = y; //纵坐标设置
HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE); //获取控制台句柄
SetConsoleCursorPosition(handle, pos); //设置光标位置
}
//输入:x
//输出:无
//作用:设置屏幕颜色0-15编号
void color(short x)
{
SetConsoleTextAttribute(GetStdHandle(STD_OUTPUT_HANDLE), x % 16);
}
//输入:休眠时长x,单位毫秒
//输出:无
//作用:进程休眠x毫秒
void delay_ms(int x)
{
Sleep(x);
}
新建工程文件minesweeper.h,minesweeper.h中包含了工程中需要用到的文件,例如:标准输入输出stdio.h、控制台操作接口api.h、读取系统时钟time.h、随机数操作stdlib.h、不带回显的输入函数getch所需头文件conio.h。
minesweeper.h中宏定义了扫雷宽度MINE_LINE和高度MINE_ROW都是16。
定义了一种新的数据类型MAP。MAP中is_mine为1表示该点是地雷,0表示不是地雷。is_click为1表示该点被点击过,为0表示未被点击。is_sign为1表示被标记了,is_sign为0表示没有标记该点,num_mine表示该点周围九宫格内地雷数量。
声明了一些包括MAP类型数组在内的一些全局变量,这些全局变量必须要在minesweeper.c中定义,使用时会分别介绍。
#ifndef SAOLEI_H
#define SAOLEI_H
//头文件包含-----------------------------------------------
#include <stdio.h> //标准输入输出
#include "api.h" //控制台光标操作函数封装
#include <time.h> //获取时间time
#include <stdlib.h> //随机数rand、srand
#include <conio.h> //getch函数头文件
//宏定义---------------------------------------------------
#define MINE_LINE 16 //扫雷函数
#define MINE_ROW 16 //扫雷列数
//结构体类型定义-------------------------------------------
typedef struct
{
char is_mine; //是否是地雷
char is_click; //是否被点击
char is_sign; //是否被标记
char num_mine; //周边地雷数量
}MAP;
//全局变量声明---------------------------------------------
extern char buff[64]; //系统命令缓冲
extern MAP map[MINE_LINE + 2][MINE_ROW + 2]; //地图信息
extern int num_of_mines; //雷的总数
extern int num_of_clicks; //点开地图的次数
extern int temp_time; //进入游戏的时刻
#endif
minesweeper.c文件中需要包含头文件minesweeper.h,minesweeper.c文件主要是全局变量定义与函数具体实现。
#include "minesweeper.h"
//全局变量定义-----------------------------------------------
char buff[64] = { 0 }; //系统命令缓冲
MAP map[MINE_LINE + 2][MINE_ROW + 2] = { {0} }; //地图信息
int num_of_mines = 0; //雷的总数
int num_of_clicks = 0; //点开地图的次数
int temp_time = 0; //进入游戏的时刻
main.c文件需要包含一些头文件。main.c中只有一个主函数,以及主函数对子函数的调用,目前主函数仅用于在屏幕上显示ok。
#include <stdio.h>
#include "api.h"
#include "minesweeper.h"
int main()
{
printf("ok\n");
return 0;
}
运行结果为:
使用system("mode con lines=20 cols=80");
可以将程序窗口设置为高20字符、长80字符。根据这个函数,我们可以先将字符串写入全局变量buff数组中,再使用system函数。将main函数修改为
#include <stdio.h>
#include "api.h"
#include "minesweeper.h"
int main()
{ //设置控制台窗口大小
sprintf(buff, "mode con lines=%d cols=%d", MINE_LINE + 2, MINE_ROW * 2);
system(buff);
return 0;
}
运行结果为:窗口大小被修改了
在minesweeper.c结尾添加函数定义,定义游戏主体运行game_play()函数
//函数定义---------------------------------------------------
//作用;游戏主体程序,对按键处理
void game_play()
{
printf("game_play\n");
}
在minesweeper.h添加声明函数声明game_play,注意函数声明放在#endif语句之前
//函数声明-------------------------------------------------
//作用;游戏主体程序,对按键处理
void game_play();
在main()函数中调用该函数,编译尝试
int main()
{ //设置控制台窗口大小
sprintf(buff, "mode con lines=%d cols=%d", MINE_LINE + 2, MINE_ROW * 2);
system(buff);
game_play();
return 0;
}
运行结果为:说明main函数正常运行,调用了game_play()函数。
到目前为止,我们已经搭建了该应用程序的框架,只需完成相应的子函数即可。
按照刚刚添加game_play函数的方法,在minesweeper.c中添加函数print_welcome,用于输出提示信息
//作用:打印欢迎界面,操作介绍
void print_welcome()
{
printf("welcome to minesweeper!\n");
printf("欢迎来到扫雷游戏\n");
printf("我们可以操纵wsad\n");
printf("控制光标上下左右移动\n");
printf("j点击,k标记或取消标记\n");
printf("t提示,e退出游戏\n");
goto_xy((2 * MINE_ROW - 12) / 2, MINE_LINE);
color(12);
printf("按任意键继续\n");
color(7);
getch();
}
在minesweeper.h中添加函数声明
//作用:打印欢迎界面,操作介绍
void print_welcome();
在game_play()函数中调用print_welcome测试一下:
//作用;游戏主体程序,对按键处理
void game_play()
{
print_welcome();
}
运行结果为:
相同方法添加并声明子函数print_main(),该函数用于设置控制台大小,打印游戏界面
//作用:设置控制台大小,打印游戏主界面
void print_main()
{
//设置控制台窗口大小
sprintf(buff, "mode con lines=%d cols=%d", MINE_LINE + 2, MINE_ROW * 2);
system(buff);
system("cls"); //清屏
//打印用时
goto_xy(0, MINE_LINE + 1);
printf("用时:");
}
在game_play中调用:
//作用;游戏主体程序,对按键处理
void game_play()
{
print_welcome();
print_main();
}
运行结果为:
随意按一个键后进入游戏界面:
再次添加并声明create_map(int n)函数,该函数用于初始化地图。
创建地图时,用了随机变量与时间变量:
//作用:创建并初始化地图信息,
// 随机产生n个地雷
// 统计每个格子周围地雷数量
// 记录开始游戏的时刻
void create_map(int n)
{
int i, j, line, row;
num_of_mines = n; //总共n个雷
for (i = 0; i < MINE_LINE + 2; i++) //地图内容先初始化为0
for (j = 0; j < MINE_ROW + 2; j++)
{
map[i][j].is_mine = 0;
map[i][j].is_click = 0;
map[i][j].is_sign = 0;
map[i][j].num_mine = 0;
}
//一下代码作用:随机寻找一个点,如果该点不是地雷,则将他变为地雷,重复n次
//line[1,MINE_LINE]
//row[1,MINE_ROW]
//在范围内选取n个地雷
srand((unsigned int)time(NULL)); //重置随机数种子
while (n--) //总共找n个地雷
{
do
{
line = rand() % MINE_LINE + 1; //[1,MINE_LINE]找行号
row = rand() % MINE_ROW + 1; //[1,MINE_ROW]找列号
} while (map[line][row].is_mine != 0); //找到非地雷的点,就退出do while循环
map[line][row].is_mine = 1; //放置地雷
}
//统计每个位置周围地雷数量
for (line = 1; line <= MINE_LINE; ++line) //遍历所有点
for (row = 1; row <= MINE_ROW; ++row)
{
map[line][row].num_mine = 0;
for (i = line - 1; i <= line + 1; ++i) //每个点统计3*3区域
for (j = row - 1; j <= row + 1; ++j)
{
if (map[i][j].is_mine == 1)
map[line][row].num_mine++;
}
}
temp_time = (int)time(NULL); //进入游戏时刻
}
再使用相同方式声明并定义print_xy函数,用于打印指定位置的一个地图:
//作用:打印map中l行r列地图。tip为1时开启作弊提示,否则关闭提示
void print_xy(int l, int r,int tip)
{
goto_xy((r - 1) * 2, (l - 1));
if (map[l][r].is_click == 0) //没有点击过
{
if (map[l][r].is_sign == 0) //没有点击、没有标记过
{
if (tip == 1) //开启作弊模式
{
if (map[l][r].is_mine == 0)
printf("■"); //作弊模式下未点击、未标记的非地雷点
else
printf("x "); //作弊模式下未点击、未标记的地雷点
}
else //没有作弊模式
printf("■"); //没有作弊的未点击、未标记的点
}
else
printf("□"); //没有点击、但是标记了
}
else //点击过的地图
{
if (map[l][r].is_mine == 1)
{
printf("※"); //点击过并且是地雷
}
else
{
if (map[l][r].num_mine == 0) //点击过、不是地雷、周围没有地雷:输出空格
printf(" ");
else //点击过、不是地雷、周围有地雷:输出地雷个数
printf("%d ", map[l][r].num_mine);
}
}
}
相同方式声明并定义函数print_map,该函数用于输出全部地图
//作用:打印游戏地图,tip为1是开启作弊提示,否则关闭提示
void print_map(int tip)
{
int i, j;
for (i = 1; i <= MINE_LINE; ++i)
{
for (j = 1; j <= MINE_ROW; ++j)
{
print_xy(i, j, tip);
}
}
}
在game_play中调用创建地图函数create_map、打印地图函数print_map。
创建地图时地雷数量为1/6,定义时使用带作弊的打印(不带作弊打印,由于全部没有点击过,全都是实心方块)
//作用;游戏主体程序,对按键处理
void game_play()
{
print_welcome();
print_main();
create_map(MINE_LINE * MINE_ROW / 6);
print_map(1);
}
运行结果为:
按键按下之后:
假设我们将光标从(1,1)移动到(1,2),我们想要先清空重新输出(1,1)点的地图,并且移动光标到(1,2)点。添加函数updata用于移动光标。
//作用:更新光标位置,更新时间信息
void updata(int *l_last,int* r_last,int line, int row)
{
//重新打印原来的位置
print_xy(*l_last, *r_last, 0);
//在用时后面显示具体时间,单位秒
goto_xy(6, MINE_LINE + 1);
printf("%d", (int)time(NULL) - temp_time);
//移动光标到新位置
goto_xy((row - 1) * 2, (line - 1));
//更新光标坐标
*l_last = line;
*r_last = row;
}
再添加胜利函数和失败函数,注意失败函数中需要打印所有地雷位置,如下:
//作用:游戏结束,更新时间,显示所有地雷位置
void game_over()
{
int i, j;
for (i = 1; i <= MINE_LINE; ++i) //遍历所有地图
for (j = 1; j < MINE_LINE; ++j)
if (map[i][j].is_mine == 1) //如果是地雷
{
map[i][j].is_click = 1;//显示出所有地雷
updata(&i, &j, i, j);
}
goto_xy(0, MINE_LINE + 1);
printf("游戏结束,按任意键退出");
getch();
exit(0);
}
//作用:游戏胜利
void game_win()
{
goto_xy(0, MINE_LINE + 1);
printf("你赢了");
getch();
exit(0);
}
这样一来,程序子函数就完成了,我们只需完善game_play对按键的处理即可。思路如下:
- 定义字符变量保存临时按键值,定义变量保存光标当前位置和之前位置。
- 无回显获取字符,
-
- 如果是asdw执行相应光标移位及更新。
-
- 如果是j则当前光标点击操作,并且判断是否游戏胜利或失败
-
- 如果是k则当前光标标记操作
-
- 如果是e则退出游戏
-
- 如果是t,则打印作弊地图,持续3秒,再打印普通地图
具体如下:
- 如果是t,则打印作弊地图,持续3秒,再打印普通地图
//作用;游戏主体程序,对按键处理
void game_play()
{
char c; //临时字符
int l_last = 1, r_last = 1; //之前光标
int line = 1, row = 1; //当前光标
print_welcome(); //输出欢迎界面
print_main(); //打印游戏界面
create_map(MINE_LINE * MINE_ROW / 6);//创建游戏地图,参数为地雷个数
print_map(0); //打印游戏地图(不带提示)
updata(&l_last, &r_last, line, row); //更新光标位置
while (c=getch())
{
switch (c)
{
case 'w'://上移
if (line > 1) //不在顶行
{
--line;
updata(&l_last, &r_last, line, row);
}
break;
case 's'://下移
if (line < MINE_LINE)//不在底行
{
++line;
updata(&l_last, &r_last, line, row);
}
break;
case 'a'://左移
if (row > 1) //不在最左
{
--row;
updata(&l_last, &r_last, line, row);
}
break;
case 'd'://右移
if (row < MINE_ROW)//不在最右
{
++row;
updata(&l_last, &r_last, line, row);
}
break;
case 'j'://点击
if (map[line][row].is_click == 0 && map[line][row].is_sign == 0)
{//未被点击过未被标记过
num_of_clicks++; //点开的格子数量加1
map[line][row].is_click = 1;
updata(&l_last, &r_last, line, row);
}
if (map[line][row].is_mine == 1) //点到地雷
{
game_over();
}
if (num_of_clicks + num_of_mines == MINE_LINE * MINE_ROW)
{
game_win();
}
break;
case 'k'://标记或取消
if (map[line][row].is_click==0)
{//未被点击的方块
if (map[line][row].is_sign == 0) //未被标记过就标记
map[line][row].is_sign = 1;
else //标记过就解除标记
map[line][row].is_sign = 0;
updata(&l_last, &r_last, line, row);
}
break;
case 't'://提示
print_map(1); //作弊打印地图
Sleep(1000); //等1秒
print_map(0); //普通打印地图
updata(&l_last, &r_last, line, row); //更新光标位置
break;
case 'e'://退出
exit(0);
break;
}
}
}
运行结果为:
按asdw控制光标,j点击,如图
如果确定地雷位置后,可以使用k标记,如图
按t进入3秒提示状态
按e退出游戏
应用封装
我们程序此时只能在我们自己电脑的编译器中运行,要想变成一个真正的可以在windows系统中通用的应用程序还需进行程序封装,这已经无关乎C语言本身了,但是出于实用性考虑,我觉得还是有必要演示一下。我是用的是windows10中的vs开发环境,结果不一样可以自行百度搜索如何封装。由于我们工程较小,直接采用静态编译即可;大型程序需要打包处理。
封装测试以实战前准备章节中的滚动字幕为例:
在VS编译器上编写的程序都会生成一个exe文件,有时候写了一个很装逼的程序想在别人电脑炫耀一下,奈何将这个exe文件拷贝过去并不能运行,直接宣告装逼失败。为此将介绍一下如何将生成的exe文件在其他电脑上运行,步骤如下:
1、在编译界面点击项目选项卡,在下拉菜单中选择属性。
2、点击属性后便会打开下图的窗口,点击配置属性的高级,在MFC的使用处选择在静态库中使用MFC。
3、点击配置属性中的C/C++选项中选择代码处理,在右侧的运行库处选择多线程调试(/MTD)。
4、编译之后,在工程目录下回出现该文件夹,里面的exe可以跨电脑使用。
这样可以解决简单程序的打包问题,此时产生的exe程序可以在window系统通用。如果工程较大的话还是选用打包吧。
参考例程
链接:https://pan.baidu.com/s/1kCW-2Qznsmsl69_hCarATQ
提取码:237r
特殊部分——指针专辑
本模块看似内容很多,其实不然,真正难点在于动态内存和文件操作。在此之前的内容例如指针与修饰符、指针与类型转换等,篇幅相对较少,内容也相对简单。看完本章节,相信大家将会对C语言有一个新的认识与理解。