前言

之前写过了CPP,C#,Lua的语言基础 现在把三种语言的类与对象整理到一起。

简介

此部分讲解:类型 方法 继承

类别 描述 示例代码/概念
值类型 Struct默认继承自object struct MyStruct { /* ... */ }
引用类型 Class默认继承自object public class MyClass { /* ... */ }
接口 声明我是什么样子的类型(类型规范),不实现 public interface ILifeCycle { /* ... */ }
抽象类 不能实例化,可以包含抽象方法,可以暂时不实现接口,等待子类实现 public abstract partial class Actor : ILifeCycle { /* ... */ }
继承 Class只能继承1或0个基类,但可以继承多个接口 public class Mage : Actor, ILifeCycle, IDestroy { /* ... */ }
结构体与继承 Struct不能继承Struct或Class,但可以继承接口 struct MyStruct : IMyInterface { /* ... */ }
变量存储 父类变量可以存储子类变量(地址),不能反向存储 Actor actor = new Mage();
面向对象概念 子类是一种特殊的父类 A is Human, B is Teacher. 学生是人,但人不一定都是老师.

面向对象的三大特点

  • 继承: 提高代码重用度,增强软件可维护性的重要手段,符合开闭原则。继承最主要的作用就是把子类的公共属性集合起来,便与共同管理,使用起来也更加方便。你既然使用了继承,那代表着你认同子类都有一些共同的特性,所以你把这些共同的特性提取出来设置为父类。继承的传递性:传递机制 a->b; b->c; c具有a的特性 。继承的单根性:在C#中一个类只能继承一个类,不能有多个父类。

  • 封装: 封装是将数据和行为相结合,通过行为约束代码修改数据的程度,增强数据的安全性,属性是C#封装实现的最好体现。就是将一些复杂的逻辑经过包装之后给别人使用就很方便,别人不需要了解里面是如何实现的,只要传入所需要的参数就可以得到想要的结果。封装的意义在于保护或者防止代码(数据)被我们无意中破坏。

  • 多态性: 多态性是指同名的方法在不同环境下,自适应的反应出不同得表现,是方法动态展示的重要手段。多态就是一个对象多种状态,子类对象可以赋值给父类型的变量。

多态与虚函数-CPP

多态

什么是多态?C++的多态是如何实现的?

所谓多态,就是同一个函数名具有多种状态,或者说一个接口具有不同的行为;C++的多态分为编译时多态和运行时多态,编译时多态也称为为静态联编,通过重载和模板来实现,运行时多态称为动态联编,通过继承和虚函数来实现。

虚函数

编译器怎么实现多态的 (虚函数的实现原理是什么)

虚函数是通过虚函数表来实现的,虚函数表包含了一个类(所有)的虚函数的地址,在有虚函数的类对象中,它内存空间的头部会有一个虚函数表指针(虚表指针),用来管理虚函数表。当子类对象对父类虚函数进行重写的时候,虚函数表的相应虚函数地址会发生改变,改写成这个虚函数的地址,当我们用一个父类的指针来操作子类对象的时候,它可以指明实际所调用的函数。

【游戏开发面经汇总】- 计算机基础篇 - 知乎

构造函数与析构函数

构造和析构能是虚函数吗

  • 为什么父类析构函数必须是虚函数?为什么C++默认析构函数是虚函数。

通常将父类的析构函数设为虚函数。如果父类的析构函数不是虚函数,则不会触发动态绑定(多态),结果就是只会调用父类的析构函数,而不会调用子类的析构函数,造成内存泄漏

C++默认构造函数不是虚函数,是因为虚函数需要虚函数表虚表指针会占用额外内存。如果一个类没有子类,就没有必要将析构函数设为虚函数。

为什么父类析构函数必须为虚函数

  • 构造函数不能是虚函数,因为在对象构造过程中其内存布局和虚函数表(vtable)尚未完全初始化,无法进行虚函数调用。
  • 析构函数可以也是应当为虚函数,以确保通过基类指针删除派生类对象时能够调用正确的析构函数,从而实现正确的资源管理和释放。
  • 构造函数:禁止虚函数(语法限制,逻辑无意义)。
  • 析构函数:基类析构函数应声明为虚函数,避免资源泄漏,保障多态对象安全销毁。

构造函数和析构函数是否能调用虚函数

答:在C++ primer中说到过是最好不要调用,不是不能调用,所以构造函数跟虚构函数里面都是可以调用虚函数的,并且编译器不会报错。但是在基类中声明纯虚函数并且在基类的析构函数中调用,编译器会报错。

对于底层:

当在构造基类部分时,派生类还没被完全创建。即当A::A()执行时,B类对象还没被完全创建,此时它被当成一个A对象,而不是B对象,因此Function()绑定的是A的Function()。

基类部分在派生类部分之前被构造,当基类执行构造函数时,派生类中的数据成员还未被初始化。如果在基类构造函数中调用虚函数被解析成调用派生类的虚函数,而派生类的虚函数中又访问到未初始化的派生类数据,这是危险的,将会导致程序出现未知行为及bug。

  • 在构造函数中调用虚函数时,派生类对象尚未完全构造,因此调用的是当前类的虚函数。
  • 在析构函数中调用虚函数时,派生类对象已经部分销毁,因此调用的是当前类的虚函数。

构造函数和析构函数中能否调用虚函数? - 知乎

复杂继承情况

虚函数

  • 虚函数多态时机:

    • 通过基类指针/引用调用时触发动态绑定。

    • 直接通过对象实例调用则为静态绑定。

img

  1. 对于非虚函数,三个类中虽然都有一个叫 func2 的函数,但他们彼此互不关联,因此都是各自独立的,不存在重载一说,在调用的时候也不需要进行查表的操作,直接调用即可。
  2. 由于子类B和子类C都是继承于基类A,因此他们都会存在一个虚指针用于指向虚函数表。注意,假如子类B和子类C中不存在虚函数,那么这时他们将共用基类A的一张虚函数表,在B和C中用虚指针指向该虚函数表即可。但是,上面的代码设计时子类B和子类C中都有一个虚函数 vfunc1,因此他们就需要各自产生一张虚函数表,并用各自的虚指针指向该表。由于子类B和子类C都对 vfunc1 作了重载,因此他们有三种不同的实现方式,函数地址也不尽相同,在使用的时候需要从各自类的虚函数表中去查找对应的 vfunc1 地址。
  3. 对于虚函数 vfunc2,两个子类都没有进行重载操作,所以基类A、子类B和子类C将共用一个 vfunc2,该虚函数的地址会分别保存在三个类的虚函数表中,但他们的地址是相同的。
  4. 从上图可以发现,在类对象的头部存放着一个虚指针,该虚指针指向了各自类所维护的虚函数表,再通过查找虚函数表中的地址来找到对应的虚函数。
  5. 对于类中的数据而言,子类中都会包含父类的信息。如上例中的子类C,它自己拥有一个变量 m_data1,似乎是和基类中的 m_data1 重名了,但其实他们并不存在联系,从存放的位置便可知晓。

动态绑定有以下三项条件要符合:

  1. 使用指针进行调用
  2. 指针属于up-cast后的
  3. 调用的是虚函数

与动态绑定相对应的是静态绑定,它属于编译的时候就确定下来的,如上文的非虚函数,他们是类对象直接可调用的,而不需要任何查表操作,因此调用的速度也快于虚函数。

C++中的虚指针与虚函数表 - 知乎 (zhihu.com)

虚函数内存模型

这里写图片描述

C++多态虚函数表详解(多重继承、多继承情况)_一个类有几个虚函数表-CSDN博客

多重继承

这里写图片描述

多继承

在这里插入图片描述

钻石继承

钻石(菱形)继承存在什么问题,如何解决

【参考资料】:C++之钻石问题和解决方案(菱形继承问题)_Benson的专栏-CSDN博客C++:钻石继承与虚继承 - Tom文星 - 博客园 (cnblogs.com)

钻石继承例子:

D -> B, C ->A

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/*
Animal类对应于图表的类A
*/

class Animal { /* ... */ }; // 基类
{
int weight;

public:

int getWeight() { return weight;};

};

class Tiger : public Animal { /* ... */ };

class Lion : public Animal { /* ... */ }

class Liger : public Tiger, public Lion { /* ... */ };

在上面的代码中,我们给出了一个具体的钻石问题例子。Animal类对应于最顶层类(图表中的A),Tiger和Lion分别对应于图表的B和C,Liger类(狮虎兽,即老虎和狮子的杂交种)对应于D。

  • 解决方法:

答:会存在二义性的问题,因为两个父类会对公共基类的数据和方法产生一份拷贝,因此对于子类来说读写一个公共基类的数据或调用一个方法时,不知道是哪一个父类的数据和方法,也会导致编译错误。

可以采用虚继承的方法解决这个问题 (父类继承公共基类时用virtual修饰),这样就只会创造一份公共基类的实例,不会造成二义性。

1
2
3
class Tiger : virtual public Animal { /* ... */ };

class Lion : virtual public Animal { /* ... */ }

接口与抽象类-C#

纯虚函数

纯虚函数–cpp

两者的区别在于纯函数尚未被实现,定义纯虚函数是为了实现一个接口。在基类中定义纯虚函数的方法是在函数原型后加=0

1
virtual void function() = 0;

接口(抽象类)是什么

抽象类(接口)是一种特殊的类,不能定义对象,需要满足以下条件:

  • 类中没有定义任何的成员变量

  • 所有的成员函数都是公有的

  • 至少一个成员函数是纯虚函数

子类继承接口,需要实现接口的全部的方法。

Interface与abstract之间的不同

  • 省流:
    • 接口不是类 不能实例化 抽象类可以间接实例化
    • 接口是完全抽象抽象类为部分抽象
    • 接口可以多继承 抽象类是单继承
  • 异同点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
相同点:
(1) 都可以被继承
(2) 都不能被实例化
(3) 都可以包含方法声明
(4) 派生类必须实现未实现的方法
区 别:
(1) 抽象基类可以定义字段、属性、方法实现。接口只能定义属性、索引器、事件、和方法声明,不能包含字段。
(2) 抽象类是一个不完整的类,需要进一步细化,而接口是一个行为规范。微软的自定义接口总是后带able字段,证明其是表述一类“我能做。。。”
(3) 接口可以被多重实现,抽象类只能被单一继承
(4) 抽象类更多的是定义在一系列紧密相关的类间,而接口大多数是关系疏松但都实现某一功能的类中
(5) 抽象类是从一系列相关对象中抽象出来的概念, 因此反映的是事物的内部共性;接口是为了满足外部调用而定义的一个功能约定, 因此反映的是事物的外部特性
(6) 接口基本上不具备继承的任何具体特点,它仅仅承诺了能够调用的方法
(7) 接口可以用于支持回调,而继承并不具备这个特点
(8) 抽象类实现的具体方法默认为虚的,但实现接口的类中的接口方法却默认为非虚的,当然您也可以声明为虚的
(9) 如果抽象类实现接口,则可以把接口中方法映射到抽象类中作为抽象方法而不必实现,而在抽象类的子类中实现接口中方法
  • 接口和抽象类相同点

    • 都是不断抽取出来的抽象概念
  • 接口和抽象类的区别

    • 接口是行为的抽象,是一种行为的规范,接口是like a 的关系;抽象是对类的抽象,是一种模板设计,抽象类是is a 的关系。
    • 接口没有构造方法,而抽象类有构造方法,其方法一般给子类使用
    • 接口只有定义,不能有方法的实现,java 1.8中可以定义default方法体,而抽象类可以有定义与实现,方法可在抽象类中实现。
    • 抽象体现出了继承关系,继承只能单继承。接口提现出来了实现的关系,实现可以多实现。接口强调特定功能的实现,而抽象类强调所属关系。
    • 接口成员变量默认为public static final,必须赋初值,不能被修改;其所有的成员方法都是public abstract的。抽象类中成员变量默认default,可在子类
    • 中被重新定义,也可被重新赋值;抽象方法被abstract修饰,不能被private、static、synchronized和native等修饰,必须以分号结尾,不带花括号。

重载 重写 覆写

定义

以C#为例:

一、override重写,是在子类中重写父类中的方法,两个函数的函数特征(函数名、参数类型与个数)相同。用于扩展或修改继承的方法、属性、索引器或事件的抽象或虚拟实现。提供从基类继承的成员的新实现,而通过override声明重写的方法称为基方法。
注意事项:
1.重写基方法必须具有与override方法相同的签名。
2.override声明不能更改virtual方法的可访问性,且override方法与virtual方法必须具有相同级别访问修饰符。
3.不能用new、static、virtual修饰符修改override方法。
4.重写属性声明必须指定与继承的属性完全相同的访问修饰符、类型和名称。
5.重写的属性必须是virtual、abstract或override。
6.不能重写非虚方法或静态方法。
7.父类中有abstract,那么子类同名方法必定有override,若父类中有 virtual方法,子类同名方法不一定是override,可能是overload。
8.override必定有父子类关系。

二、overload重载,在同一个类中方法名相同、参数或返回值不同的多个方法即为方法重载。
注意事项:
1.出现在同一个类中。
2.参数列表不同或返回类型和参数列表都不同,只有返回类型不同不能重载。(参数列表包括参数个数和参数类型)

三、overwrite覆写,用new实现。在子类中用 new 关键字修饰定义的与父类中同名的方法,也称为覆盖,覆盖不会改变父类方法的功能。

C# 关键字:overload、override、new

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
class Parent
{
public void F()
{
Console.WriteLine("Parent.F()");
}
public virtual void G() //抽象方法
{
Console.WriteLine("Parent.G()");
}
public int Add(int x, int y)
{
return x + y;
}
public float Add(float x, float y) //重载(overload)Add函数
{
return x + y;
}
}
class ChildOne:Parent //子类一继承父类
{
new public void F() //覆写(overwrite)父类函数
{
Console.WriteLine("ChildOne.F()");
}
public override void G() //重写(override)父类虚函数,主要实现多态
{
Console.WriteLine("ChildOne.G()");
}
}
class ChildTwo:Parent //子类二继承父类
{
new public void F()
{
Console.WriteLine("ChildTwo.F()");
}
public override void G()
{
Console.WriteLine("ChildTwo.G()");
}
}
class Program
{
static void Main(string[] args)
{
Parent childOne = new ChildOne();
Parent childTwo = new ChildTwo();
//调用Parent.F()
childOne.F();
childTwo.F();
//实现多态
childOne.G();
childTwo.G();
Parent load = new Parent();
//重载(overload)
Console.WriteLine(load.Add(1, 2));
Console.WriteLine(load.Add(3.4f, 4.5f));
Console.Read();
}
}

C#关键字之override详解_public override void-CSDN博客

重载和重写的区别

  • 封装、继承、多态所处位置不同,重载在同类中,重写在父子类中。

  • 定义方式不同,重载方法名相同参数列表不同,重写方法名和参数列表都相同。

  • 调用方式不同,重载使用相同对象以不同参数调用,重写用不同对象以相同参数调用。

  • 多态时机不同,重载时编译时多态,重写是运行时多态。

以CPP为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
//overload
class A{
void fun();
void fun(int i);
void fun(int i, int j) ()
};

//override
class A
{
public:
virtual void fun(){
cout << "A";
}
};

class B :public A {
public:
virtual void fun(){
cout << "B";
}
};

Lua类与对象

Lua中 点和冒号区别

点 :无法传递自身,需要显示传递

冒号 :隐式传递自身

继承

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
-- SubClass.lua

require("Class")
--类的继承
SubClass = {z = 0}
--声明新的属性Z
--两步完成类Class的继承
setmetatable(SubClass, Class)
--设置类型是class
SubClass.__index = SubClass --设置表的索引为自身
--构造方法,习惯性命名new()
function SubClass:new(x, y, z)
local t = {} --初始化对象自身
t = Class:new(x, y) --将对象自身设定为父类,相当于C#里的base
setmetatable(t, SubClass) --将对象自身元表设定为SubClass
t.z = z --新属性赋值
return t
end
--定义一个新的方法
function SubClass:go()
self.x = self.x + 10
end

--重定义父类的方法,相当于override
function SubClass:test()
print(self.x, self.y, self.z)
end



类与对象写法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Object = {}

function Object:new()
local obj = {}
self.__index = self
setmetatable(obj, self)
return obj
end

function Object:subClass(className)
_G[className] = {}
local obj = _G[className]

obj.base = self

self.__index = self

setmetatable(obj, self)

return obj
end

-- 实现方法
funciton ChildClass:DoSomething()
print("Object DoSomething")
end

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
ChildClass =Object:subClass("ChildClass")

ChildClass.x=nil;

ChildClass.vector={}

funciton ChildClass:OnInit()

end

-- 重定义父类的方法,相当于override
funciton ChildClass:DoSomething()
print("ChildClass DoSomething")
self.OnInit()
self.vector:OtherClassFun()
end

封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
-- Class.lua

--类:属性,构造函数,成员,属性等等
--类的声明,属性,初始值
class = {x = 0, y = 0}
--设置元表的索引,要模拟类,这一步最重要
class.__index = class --表的元表索引是自己
--构造方法,习惯性命名new()
function class:new(x, y)
local t = {} --初始化t,如果没有这一句,会导致类所建立的对象一旦发生改变,其它对象都会改变
setmetatable(t, class) --设置t的元表为class , 把class设为t的原型
t.x = x --属性值初始化
t.y = y
return t --返回自身
end
--这里定义类的其它方法,self标识是非常重要的核心点,冒号:隐藏了self参数
function class:test()
print(self.x, self.y)
end
function class:plus()
self.x = self.x + 1
self.y = self.y + 1
end

多态

1
2
3
4
5
6
7
8
9
10
11
12
13
-- main.lua

require("Class")
require("SubClass")
local a = Class:new() -- 首先实例化父类,并调用父类方法
a:plus()
a:test()

a=SubClass:new() -- 然后实例化子类对象
a:plus() --重写
a:go() --新增
a:test() --重写

C# 类的关键词

分部类 Partial

C# 中的分部类(Partial Classes)是一种特殊的类定义方式,它允许一个类的定义被分割到多个文件中。这对于一些大型类,或者当类的定义分布在多个自动生成的代码文件中(例如,由设计器或某些工具生成的代码)时,特别有用。

分部类的定义方式很简单,只需在类名前加上 partial 关键字即可。下面是一个简单的例子:

文件 MyClass1.cs

1
2
3
4
5
6
7
8
9
10
namespace MyNamespace
{
public partial class MyClass
{
public void Method1()
{
// 方法实现
}
}
}

文件 MyClass2.cs

1
2
3
4
5
6
7
8
9
10
namespace MyNamespace
{
public partial class MyClass
{
public void Method2()
{
// 方法实现
}
}
}

在上面的例子中,MyClass 是一个分部类,它的定义被分割到了两个文件中:MyClass1.csMyClass2.cs。这两个文件都位于同一个命名空间 MyNamespace 下,并且都定义了同一个名为 MyClass 的类。

编译器在编译时,会将所有具有相同名称和命名空间的分部类定义合并成一个完整的类定义。因此,在上面的例子中,MyClass 类将包含 Method1Method2 两个方法。

需要注意的是,分部类不能分割到不同的命名空间或项目中。此外,分部类主要用于设计器生成的代码或自动代码生成工具,不建议在普通的手动编写的代码中使用。

密封sealed

一、核心特性与语法规则

  1. 用于类
    • 作用:声明密封类,禁止其他类继承
    • 语法:sealed class ClassName { ... }
    • 限制:不可与 abstract 修饰符共用,因为抽象类必须被继承。
  2. 用于方法和属性
    • 作用:阻止派生类重写已继承的虚方法或属性。
    • 语法:必须与 override 结合使用,如 sealed override void Method() { ... }
    • 前提:仅能对基类中声明为 virtual 或已重写的成员进行密封。

二、常见应用场景

  1. 封装与安全性
    • 在第三方类库中,防止客户端代码通过继承修改核心逻辑(如加密算法、支付接口)。
    • 避免继承滥用导致类层次结构混乱,例如工具类或静态辅助类。
  2. 性能优化
    • 密封类的方法调用可被JIT编译器优化为非虚调用,减少运行时虚方法表(vtable)的查找开销。
    • 适用于高频调用的方法(如游戏引擎中的渲染循环)。

三、代码示例修正与解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
using System;

namespace Example86
{
class Program
{
class A
{
public virtual void F()
{
Console.WriteLine("A.F");
}
public virtual void G()
{
Console.WriteLine("A.G");
}
}

class B : A
{
public sealed override void F() // 密封方法,禁止进一步重写
{
Console.WriteLine("B.F");
}
public override void G()
{
Console.WriteLine("B.G");
}
}

class C : B
{
// public override void F() { } // 错误:无法重写密封方法
public override void G()
{
Console.WriteLine("C.G"); // 允许重写非密封方法
}
}

static void Main(string[] args)
{
new A().F(); // 输出:A.F
new A().G(); // 输出:A.G
new B().F(); // 输出:B.F
new B().G(); // 输出:B.G
new C().F(); // 输出:B.F(继承自B的密封方法)
new C().G(); // 输出:C.G
Console.ReadLine();
}
}
}

四、执行结果说明

  • B.F 的密封性:类 C 无法重写 F(),因此 new C().F() 仍调用 B.F()
  • G() 的可扩展性:类 C 可自由重写非密封方法 G(),输出自定义结果。