结合Unity开发中一些编程基础概念与原理性知识总结

 目录:

//里氏替换

//抽象类和接口

//虚函数(方法)和抽象方法、普通方法、重载、重写

//指针、引用、值类型、引用类型

//程序、进程、线程、死锁、协程

//内存的几个分区及其作用

//const和#define

//访问权限修饰符及其作用



//里氏替换

先说下概念:凡是爹出现的地方都可以用儿子代替任何基类可以出现的地方,子类一定可以出现

当初大一下学期,学面向对象的时候学C++,学到了里氏替换,father类和son类互相new着玩,搞了半天,知道什么意思了,也没知道会在哪用到,当时C++老师还说里氏替换,在编程里用的很广,不过经过一段时候的学习,确实是用的很广,而且很牛啊。

这里拿我在Unity中开发遇到的一些问题举一个不恰当,但是感觉好理解的例子。(本人才疏学浅,这里谈谈个人想法,有不对的还望指出)首先考虑下不用继承,我在一个场景中定义了十个小怪类,分别是A、B、C、D、E、F、G、H、I、J类,我想让这个场景里的小怪全都死亡怎么办?首先想到用Tag标签,然后用find方法把十个不同标签的游戏物体一类一类的都找到,然后一个个获取他们的身上的A、B、C、D、E、F、G、H、I、J脚本组件,然后调用脚本里的死亡方法?毕竟这是十个不同的类,我不能把他们存进同一个List类,然后for循环一个个的调用里面的方法,我只能一个个的分类的调用。

然后再回过头来考虑用继承,和面向对象的里氏替换特性,A、B、C、D、E、F、G、H、I、J这些儿子类都继承于O这个爸爸类,爸爸类O中有一个叫死亡的虚方法,子类可以选择重写,或者不重写。现在,我要这个场景中的小怪全都死亡,我在这里给所有类型小怪贴上Enemy标签,然后Find方法全部找到,再用GetCompent方法拿到他们身上的脚本,注意这里GetCompent的泛型约束那里,就可以直接填这些小怪的父类O类了, 我现在可以定义一个他们爸爸类的List集合,注意是爸爸类的集合然后,你可以把所有得到的小怪,全部放到这个爸爸类的集合,为什么这次可以放?因为里氏替换,所有爸爸类出现的地方,全都可以替换成子类,然后我对这个List可以直接for循环,直接调用他们的死亡方法,然后所有小怪就都死亡了。

当然这个例子顺序肯定是不对的,小怪的话,应该是每产生一个小怪的同时把它加到爸爸类集合里,再对爸爸类集合做操作要注意的是,这里爸爸类能调用的只有爸爸类中的自有的虚方法或者抽象方法,或者是能被子类重写过后的方法,不能调用到子类中所特有的方法,除非你用的时候把它强转成你要用到的子类,再进行调用。

 

//抽象类和接口

抽象类和接口,这个在当初学的时候一直纳闷,只知道这俩有啥不同,但是感觉实际用起来,没太大啥区别啊,反正C#也不许多继承,我直接用普通类和接口就好了啊,但是之后的学习中明白了,人家这么搞是有一定原因的。

先是抽象类,由abstract关键字修饰的类,先看字面意思,抽象,从具体事物抽出、概括出它们共同的方面、本质属性与关系等,而将个别的、非本质的方面、属性与关系舍弃,这种思维过程,称为抽象。

现在摆在我面前的有火车,汽车,自行车,摩托车,货车

由上文抽象这个词的定义,我可以在这其中抽取出它共同的方面,即他们都是车。

然后再仔细分,火车和货车能拉货,自行车和摩托车和货车能载人。

了解了这个,然后看下面的瞎编乱造的例子。

1.小王是一家汽修公司的员工,突然有一天老板说,小王这些日子,你给我造些车,我们不搞汽修了,改生产了,然后这让小王犯了难,这老板只说造车,也没说造啥车,小王也忘了问了,然后老板就走了,没办法啊,老板交代的事得办啊,好吧,先订个基础方案,制定些车通用的功能,凡是车都得满足这些功能,然后才能称为车嘛,然后再让下面工人拿着这个基础方案,在这个基础方案上面去搞。

2.过了些天,拿到的第一个需求是做一辆小型汽车,工人就在想,这个汽车怎么做呢?想了半天也没想出来,以前光修车了,也没做过汽车,这可咋弄,算了,先按给的基础方案,把车的基础方案给做了,先搭个车架子嘛,于是开始看王经理给的基础方案,一步步的,把基本的车的功能给实现了。

3.过了几天,王经理过来看进度,看到基础的车架子已经弄完了,但是这还不是汽车,在这个车架子的基础上还能做许多别的种类的车,工人不知道下一步该咋做了,但是王经理经过这几天的学习,也基本懂了一个汽车都有啥功能了,于是又针对汽车的功能,列了张表,表上写着由车架子到变成汽车应该实现怎么样的功能,行,工人们拿到了这张表,就准备在车架子上开始动工,拿到需求表就必须全部搞完,要不然扣钱,然后就开始造车了。

过了些日子,王经理又回车间了,看到了按上次给的需求表造的车,这次,已经是一个完整的汽车了。

如法炮制,他们公司又一个个的造了火车,货车,自行车,摩托车。

第一段中的基础方案就是抽象类,抽象的一个车类,然后这也就是解释了为什么抽象类要设计成无法被实例化,车这个东西并没有具体指代一个什么东西,只是一类东西的一个总称,所以你实例化一个泛泛的车类有什么用?满足所有车的通用功能就是完成抽象类中所定义的抽象方法,基础方案就是抽象类中所有子类的公有部分;

第二段中的需求表就是接口,需求表里的需求就是接口里需要实现的方法(是不是很像?只是说我要干嘛干嘛,但是不说具体怎么做);

第三段中就是实现接口的过程;

但是实际上接口的用法应该并不太能这么用,而是应该提取出来一组功能,我需要让这个抽象类的子类实现什么样子的功能,我就让这个子类去继承什么接口。

 

为了防止忘记,这里转下大佬给总结的这俩东西区别和各自的特点。

抽象类不能创建实例,它只能作为父类被继承。抽象类是从多个具体类中抽象出来的父类,它具有更高层次的抽象。从多个具有相同特征的类中抽象出一个抽象类,以这个抽象类作为其子类的模板,从而避免了子类的随意性。

(1) 抽象方法只作声明,而不包含实现,可以看成是没有实现体的虚方法

(2) 抽象类不能被实例化 

(3) 抽象类可以但不是必须有抽象属性和抽象方法,但是一旦有了抽象方法,就一定要把这个类声明为抽象类

(4) 具体派生类必须覆盖基类的抽象方法

(5) 抽象派生类可以覆盖基类的抽象方法,也可以不覆盖。如果不覆盖,则其具体派生类必须覆盖它们

(1) 接口不能被实例化

(2) 接口只能包含方法声明

(3) 接口的成员包括方法、属性、索引器、事件

(4) 接口中不能包含常量、字段(域)、构造函数、析构函数、静态成员

 

//虚函数(方法)和抽象方法、普通方法、重载、重写

这几个感觉应该放一起来说:

       先是得说虚函数的实现原理,首先,在我们写代码,当你想Ctrl+F5要运行的,进入编译阶段的时候,首先编译器会给这个类加上一个指针,并创建一个虚函数表,这个指针指向编译器为我们这个类创建的虚函数表,也就是一个指针数组,这个指针数组里的每一个成员都是指针,都指向这个类中不同的虚函数地址,所以可以把虚函数表就如同一个表一样,记录着每一个虚函数,当一个包含虚函数的爸爸类或者从爸爸类派生出来的子类实例化的时候 ,编译器按照所实例化的类中的虚函数,就会给把虚函数表指针和虚函数表创建出来,然后把虚函数指针指向虚函数表。

当一个含有虚函数的爸爸类所派生出的儿子类被实例化出来的时候,这个时候儿子类和爹类在某种意义上是同时都被创建出来的,所以会同时存在两个虚函数指针,但是这两个虚函数指针所指向的虚函数表却是同一个,也就是父类的,当子类中有对应的override方法的时候,那么那个虚函数表中所对应的方法就会被重写,这一操作实质上是使虚函数指针数组中的那个原先指向父类虚方法的那个指针,指向了子类中这个新的override过的父类的方法的地址,这样再进行调用相应虚方法的时候,首先到虚函数表中查找,这个时候查找到的方法,就是重写过的新方法,进而实现了虚函数的重写,也为里氏替换实现了重要的功能。

       再然后是虚方法和普通方法比较,父类中的虚方法(virtual关键字修饰的方法)可以被子类重写,也就是用override关键字,如果你子类中想有一个自己的内容不同的,但是名字和父类中方法相同的方法的话,而你子类想要自己有的那个方法在父类中还不是虚方法的话,那么写的话叫重载,反之,父类同名方法是虚方法,子类选择override父类中的虚方法的话,那个叫重写,这里就能扯到普通方法了,普通方法没法重写,只能覆盖。

再接着能扯到什么是抽象方法,抽象方法没有方法体,而且只能在抽象类中存在。而且必须在子类中重写全部的抽象方法。

上面这一堆其实只是性质和实现原理,而真正能感受到它们实际区别的时候是重载与重写在里氏替换时候的区别。

简单的示例代码

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ?
{

    class Class3 { public virtual void DD() { Console.WriteLine("父类中的DD方法"); } }
    class Class4 : Class3
    {
        public override void DD()
        {
            Console.WriteLine("子类中的DD方法");
        }
    }


    class 测试类
    {
        static void Main(string[] args)
        {
            Class3 obj = new Class4();
            obj.DD();
        }
    }
}

这个代码就是一个里氏替换的例子,它的运行结果“子类中的DD方法”。

而重载的就不一样了,如下。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace ?
{

    class Class3 { public void DD() { Console.WriteLine("父类中的DD方法"); } }
    class Class4 : Class3
    {
        public void DD()
        {
            Console.WriteLine("子类中的DD方法");
        }
    }


    class 测试类
    {
        static void Main(string[] args)
        {
            Class3 obj = new Class4();
            obj.DD();
        }
    }

这个代码的运行结果却是“父类中的DD方法”

 

//指针、引用、值类型、引用类型

首先,它们虽然语言不同,但是都是存在栈中的。

指针,指针是个变量,这个变量符号所指向的地址里存的是一个地址,想要对它所指向的内容操作的话需要再次寻址

引用,引用也是个变量,不过这个变量存的是在堆中的实例对象所在的地址,操作的话需要再次寻址。

C#中值类型,符号表存的地址里存的直接就是所存的实际值,不用再次寻址,而直接改变这个符号的值。所以说你拿另一个新的值类型的变量等于你现在手里的这个变量,这样子就是直接给新的值类型型变量赋了就值类型变量的值。int a=5;int b=a;(咱们在代码上写是这么写。好像是俩字母,这俩字母是给我们人看的,有了这个咱们才能高效的编程,而实际机器用的是这俩的地址,有了这俩的地址,操作的时候才能找得到这俩里面存在的值,另外这是因为咱们人直接操作地址不方便的,才引入的这种符号机制)

C#中引用类型,符号表存的地址里存的是这个引用类型变量的地址(也就是这个引用在栈中的地址),这个地址所指向的那块区域(也就是在堆那边的地址),存的东西才是相应类在堆中的实例,类似需要再次寻址那种操作,才能操作在堆中的相应类的实例。然后我们再编辑器里写,A  a=new A(); A b;   然后,让b=a,这样实际上也是按值类型的方式操作,但是只是操作了引用变量a和b的地址里存的地址(地址里存的地址,有点绕口),让引用变量b地址所存的地址的值等于了引用变量a地址里所存的地址的值。而且因为本身a和b里存的值是地址,所以现在又多了一个b里面存的也是a在堆中的实例的地址。这也是引用类型变量互相=操作只是浅拷贝的原因。

//程序、进程、线程、死锁、协程

这个要说到操作系统那里了

先说什么是程序程序是指令、数据及其组织形式的描述,进程是程序的实体,进程是操作系统分配系统资源的最小单位,进程在任务管理器上就能看到,就是我们程序的一个实例,而这个进程更严格的说法是程序的一次执行过程。

       然后是线程,进程中包含着线程,线程是处理器调度的最小单位。先说什么是处理器调度,但是说到处理器调度又不得不说线程是在代码是怎么体现的,当我们在代码中定义一个线程的时候,我们往Thread类中传的参数是什么?是一个方法,这个方法就是程序的一个线程,独立于主线程之外的另一个线程,这个线程与主线程同时执行,互不影响。而我们又知道代码是一行一行执行的,但是有些方法会很耗时,比如读取文件,或者一些会让主线程暂停的程序,比如Socket的那个Accept方法,但是实际上开发的时候,我们并不希望程序暂停在那里,我们希望的是这些耗时或者会让主线程暂停的方法,独立于主程序之外,并在开启这个线程的时候与主线程一起执行,也就是常说的异步。但是,当我们在程序中开启了许多线程呢?或者说,在我们的系统中本身就运行着许多线程,这些线程分属于不同的进程,CPU也就是我们的处理器,是怎么进行处理的呢?

我开着GTA5同时跟朋友用QQ语音,然后还通过一个音乐播放器放着歌,任务栏上还最小化着用来写这篇博客的谷歌浏览器,我们操作电脑外观上好像看着这么多程序是同时运行着的,但是实际上并不是,CPU处理机制(一个核)是在同一时刻只能处理一个线程,那么我们的计算机是如何呈现出一种所有线程同时运行的假象的呢?抛去先来先服务,最短作业优先那些,现在处理器采用的是时间片轮转调度,这个可以直接按字面意思理解,举例的话就好像,外国的那种唱片放映机,电视上经常演,一个黑色的那种大圆盘,如下图,所有运行在操作系统上的线程分布在一个大圆盘上,处理器根据线程的优先级调度,调度的意思就是每个线程运行一段时间之后挂起,紧接着去运行其他线程,其他线程运行一段时间后挂起,再去运行别的,如此往复,而由于这个切换时间及其短,就形成了我们所看到的那种效果。这个原理其实和下图这个播放出音乐的原理差不多,也和音速索尼克的分身原理一样,看着好像是突然十个人,实际上是他在每个分身位置进行高速移动,而人眼的视觉具有残留性,因为他足够快,所以就好像是同时又多出了十个人,这就等价于我们看到的十几个程序同时运行那种效果。

接着是,死锁,什么是死锁?先看标准定义:死锁是指两个或两个以上的进程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。

陷入死锁的线程实际上就是模拟了一群人拿着枪互相指着对方的脑袋,然后互相叫嚣着让对方先放下下枪。你先放下枪,然后我再放下枪。C#里面用Lock关键字来实现一时间只能有一个线程访问的资源,放到线程这边就是我拿着一个只能单独访问的资源,这个资源可以是一个变量,或者是一个别的什么东西,但是这个东西还是别的线程执行所需要的资源,而且别的线程还拿着我这个线程运行所需要的资源,一个线程运行不玩,资源没法解锁,另一个线程也就得不到下一步所需要的资源,反之亦然。

代码的话就是这种:线程1对线程2说:你把B给我,我只有执行完了才能把A给你,线程2对线程1说,你把A给我,我只有执行完了才能把B给你。

//线程1
lock(A)
{
   Lock(B)
{

}

}
//线程2
lock(B)
{
   Lock(A)
{

}

}

再然后是Unity的协程:Coroutine

首先得知道Unity所有的生命周期函数都在一个线程里,即Untiy是单线程的,它用来延时或者实现一些异步的操作;

然后得知道yield,这个英文是中断的意思;

然后协程的运行机制是在主线程里,开启一段辅助程序,这是个形功能如多线程,但却不是多线程的东西;

协程方法里必须有一个yield return XXX,这个字面意思就是中断返回的意思。

在Unity开启的协程,Unity会在每次Update之后去查看协程的执行状况,如果满足继续执行的条件,则会继续执行下面的代码。可以抽象化为水管,或者长江,长江分出来的支流,与长江一同流动,有的能和长江一起流向大海,有的就流到一定地方就不流了,协程方法也是一样,如果不是在一帧内一次性就执行完的话,最终都会通向大海,即下一个逻辑帧。

其实这么理解只能说是能用了,深入的话还需要再了解一些迭代器原理方面的知识。//TODO:后续记得补充。

 

//内存的几个分区及其作用

C++中内存有五个分区,C#的话大同小异,然后分别是栈,堆,常量区,代码区,全局变量(静态变量)区

1、栈区(stack) ,由编译器自动分配释放,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
2、堆区(heap) ,一般由程序员分配释放,若程序员不释放,程序结束时可能由OS(操作系统)回收。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表。
3、全局区(静态区),全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域,未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。程序结束后由系统释放。
4、文字常量区,常量字符串就是放在这里的。程序结束后由系统释放。
5、程序代码区,存放函数体的二进制代码。

 

//const和#define

先说#define

程序编译的几个阶段:预处理,编译,汇编,链接,然后生成可执行文件。

这个从写程序代码那开始说起,当你写完了代码,按下Ctrl+F5准备运行了,这一刻,就开始了预处理,

首先,预处理阶段都干了什么?看下面可以知道,其实就代替人手把乱七八糟的东西都剔除掉。,只剩下纯净的代码。
1:将头文件中的内容(源文件之外的文件)插入到源文件中 
2:进行了宏替换的过程,定义和替换了由#define指令定义的符号 
3:删除掉注释的过程,注释是不会带入到编译阶段 
4:条件编译

最重要的是第二条,宏替换,比如 #define a 10 这个的意思说白了就是一个简单的文本替换,当然这是在C++中,C#中不是这样用,要用的话,看这个链接:https://www.runoob.com/csharp/csharp-preprocessor-directives.html

宏替换效果就是如下图:也就是说,这个define,把这个文件中,全部的a都替换成10,不管哪是哪。而const就是说受作用域影响了。

然后是const

const的话,得先知道符号表,这里记得翻回去看对内存的理解那里。

我在程序中写了个const int a=5; int b;

表中这个a就是我刚刚定义的一个变量,预处理阶段过了,进入编译阶段过程中,就给a赋值,即5,然后其他属性里面据我估计肯定是有这个变量的类型,是私有啊,还是公有啊,是static修饰啊,还是const修饰啊,然后看b,b在这个值在编译阶段还没有赋值,当程序运行起来的时候,b有代码给它赋值的时候才会,他才会有值,这里只是给它先分配了个地址啥的。

注意的是这里的符号表画的其实并不对。应该是没有值那一属性栏的,这里只是为了方便解释。

然后当有程序想要用变量a的时候,程序就先去符号表找到叫a的那一列,完完整整的看完这一列,然后再去按照他的地址去找那对应地址里存的东西。就在这个时候,程序发现a这个变量是const类型修饰的,程序如果想用到这个a的值,那么随便用,但是是想修改它,那么对不起,不行。

 

//访问权限修饰符及其作用

public:公开 类及类成员的修饰符 对访问成员没有级别限制
private: 私有 类成员的修饰符 只能在类的内部访问
protected: 受保护的 类成员的修饰符 只能在该类和该类的派生类中访问,不管该派生类和基类是否在同一程序集中
internal: 内部的 类及类成员的修饰符 访问仅限于程序集中
protected internal: 只能修饰类成员  如果是继承关系,无论是不是在同一个程序集里都可以访问,如果不是继承关系,只能在同一个程序集中访问

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值