跳转至

02.面向对象

.NET/C#面试题汇总系列:面向对象

扩充

本节标题是面向对象,那么问题来了:

什么是面向对象编程(OOP)?

(综合了百度百科和维基百科)

面向对象 (Object Oriented) 是软件开发方法,一种编程范式。

它将对象作为程序的基本单元,将程序和数据封装其中,以提高软件的重用性、灵活性和扩展性,对象里的程序可以访问及经常修改对象相关连的数据。

而类有两个主要的概念:

类(Class):定义了一件事物的抽象特点。类的定义包含了数据的形式以及对数据的操作。

对象(Object):是类的实例(Instance)。

1.什么是构造函数?

概念:构造函数的方法名与类型相同、没有返回类型。

作用:完成对类的对象初始化。

创建一个类的新对象时,系统会自动调用该构造函数初始化新对象,如果没有写定义,那么系统会自动提供一个不带任何参数的 public 构造函数。

扩充

《C#高级编程》Ch 3.3

构造函数是在实例化对象时自动调用的特殊函数。它们必须与所属的类同名,且不能有返回类型。构造函数用于初始化字段的值。

2.class 和 struct 的区别?

在 C#中,class 和 struct 是用于定义自定义类型的两种不同的关键字,它们有一些重要的区别:

class struct
内存分配 class 是引用类型,它在堆上分配内存。对象的实例是通过引用访问的。 struct 是值类型,它在栈上分配内存。结构体的实例是通过直接访问值来操作的。
默认构造函数 class 默认有一个无参数的构造函数,如果没有显式提供构造函数,编译器会自动生成默认构造函数。 struct 不会自动生成无参数的构造函数。如果没有提供构造函数,可以使用默认的无参数构造函数创建结构体。
继承 class 支持继承,可以作为基类和派生类。可以使用 virtual 和 override 关键字实现多态性。 struct 不支持继承,不能作为基类。结构体是密封的,不能被继承。
装箱和拆箱 class 在进行值类型到引用类型的转换时会发生装箱(Boxing)和拆箱(Unboxing)。 struct 通常不会发生装箱和拆箱,因为它是值类型,但在某些情况下可能会进行拆箱操作。
可空性: class 可以为 null,因为引用类型的变量可以赋值为 null。 struct 是值类型,不可以为 null。可以使用 Nullable 结构(或简称为 T?)实现可空性。
性能 struct 的性能通常比 class 更好,因为它在栈上分配内存,避免了堆上的垃圾回收开销。但在大型对象的情况下,堆上的分配可能更适用。

使⽤场景 1.Class ⽐较适合⼤的和复杂的数据,表现抽象和多级别的对象层次时。

2.Struct 适⽤于作为经常使⽤的⼀些数据组合成的新类型,表示诸如点、矩形等主要⽤来存储数据的轻量 级对象时,偏简单值。

3.Struct 有性能优势,Class 有⾯向对象的扩展优势

总的来说,选择使用 class 还是 struct 取决于具体的需求和设计,包括对内存分配、继承、装箱拆箱等方面的考虑。

3. 简述一下面向对象的三大特性?

多态(Polymorphism)、继承(Inheritance)、封装(Encapsulation)。

  • 多态:同一个行为具有多个不同表现形式或形态的能力。在面向对象编程范式中,多态性往往表现为"一个接口,多个功能"。

多态通过方法的重载(Overload)和方法的重写(Overrid)来实现。编译时多态(静态多态)是指方法的重载,运行时多态(动态多态)是指方法的重写。静态多态分为:函数重载和运算符重载;动态多态分为:重写抽象类和重写虚方法。

多态代码示例:

/// <summary>
/// 静态多态①函数重载:在同一个范围内对相同的函数名有多个定义。
/// 函数的定义必须彼此不同,可以是参数列表中的参数类型不同,也可以是参数个数不同。
/// </summary>
public class Civ5
{
    public void SetUU()
    {

    }

    public void SetUU(string name)
    {
        Console.WriteLine(name);
    }

    public void SetUU(int ID)
    {
        Console.WriteLine(ID);
    }
}

/// <summary>
/// 动态多态性是通过 抽象类 和 虚方法 实现的。
/// 动态多态①重写抽象类的抽象方法
/// </summary>
public abstract class Civ6_1
{
    //抽象方法:只有定义
    public abstract void SetUU();//设置文明 6 的特色单位
}

public class Qin : Civ6_1
{
    public override void SetUU()//Qin(秦始皇)的特色单位
    {
        Console.WriteLine("虎蹲炮");
    }
}

/// <summary>
/// 动态多态①重写虚方法
/// </summary>
public class Civ6_2
{
    //虚方法:带方法体
    public virtual void SetUU()//设置文明 6 的特色单位
    {
        Console.WriteLine("None");
    }
}

public class Yongle : Civ6_2
{
    public override void SetUU()//Yongle(永乐皇帝)的特色单位
    {
        base.SetUU();//如果不显式调用,基类方法不会被执行
        Console.WriteLine("虎蹲炮");
    }
}
  • 继承

继承是一种机制,允许一个类(子类或派生类)继承另一个类(父类或基类)的属性和方法。

继承实现了代码的重用性,可以通过扩展已有的类来创建新的类,新类继承了已有类的特性,并可以添加或修改自己的特性。继承建立了类之间的层次关系,形成了类的继承树。

单重继承:表示一个类可以派生自一个基类。

  • 封装

封装是将对象的状态(数据)和行为(方法)包装在一起,并对外部隐藏对象的内部实现细节。

通过封装,对象的内部细节对外部是不可见的,只暴露必要的接口供其他对象进行交互。

封装提供了对对象的抽象,使得对象可以被看作是一个独立的实体,而不需要了解其内部实现。

4.构造函数是否能被重写?

构造函数不能被直接重写。构造函数不是继承的成员,因此不能使用 override 关键字进行重写。

然而,派生类可以调用基类的构造函数,并可以在派生类的构造函数中通过 base 关键字调用基类的构造函数,实现对基类构造函数的间接调用。

public class BaseClass
{
  public BaseClass()
  {
      // 基类构造函数
  }
}

public class DerivedClass : BaseClass
{
  public DerivedClass() : base()
  {
      // 派生类构造函数,调用基类构造函数
  }
}

尽管构造函数不能被直接重写,但通过调用基类构造函数,可以在派生类的构造函数中对基类进行初始化。这样可以确保在创建派生类对象时,基类的构造函数也得到正确地执行。

5. 抽象类和接口有什么区别?

抽象类和接口是两种在面向对象编程中用于实现多态性的机制,它们有一些关键的区别:

  • 相同

1、都可以被继承

2、都不能直接被实例化

3、都可以包含方法声明且都没有实现

4、派生类必须实现未实现的成员

  • 不同点

1、接口可以被多重实现,抽象类只能被单一继承。

2、接口可以用于支持回调,继承并不具备这个特点。

3、抽象类可以定义字段、方法、属性、事件、索引器的实现。接口只能定义属性、索引器、事件、和方法声明. 不能包含字段。

4、接口中的成员访问类型默认为公共的,不能有其他的访问修饰符修饰。

5、定义的关键字不一样,抽象类需要使用 abstract,而接口则使用 interface。

6..类的执⾏顺序?

执行顺序:父类的静态构造函数,子类的静态构造函数,父类的静态字段初始化,子类的静态字段初始化,父类的实例构造函数,父类的非静态字段初始化,子类的实例构造函数,子类的非静态字段初始化,方法调用。

需要注意的是,静态成员初始化和静态构造函数只会在类第一次被使用时执行,而实例构造函数和非静态成员初始化在每次创建实例时都会执行。这确保了类在使用前得到正确的初始化。

7.接⼝是否可继承接⼝?抽象类是否可实现(implements)接⼝?抽象类是否可继承实现类

(concrete class)?

在 C#中,接口是可以继承其他接口的,而抽象类既可以实现(implements)接口,也可以继承实现类(concrete class)。

C#中不支持多重继承的类(一个类继承多个类)。因此,在继承的同时,只能继承一个具体类,但可以同时实现多个接口。这种灵活性可以通过组合不同的接口和抽象类来实现。

8. 继承最大的好处?

继承最大的好处之一是代码重用。通过继承,子类可以从父类继承已有的属性和方法,无需重新编写相同的代码,从而提高了代码的复用性。这对于减少冗余代码、提高开发效率和降低维护成本都具有重要意义。

9. 请说说引用和对象?

引用(Reference):

引用是一种指向内存中对象的标识或地址。它不直接包含对象的数据,而是提供对对象的间接访问。 在堆上分配的对象通常通过引用来访问。引用可以看作是对象的地址或标签,它允许程序通过引用访问对象的内容。 引用在很多编程语言中都是一种重要的数据类型,例如,在 C#、Java 和 C++ 等语言中,引用是用于管理对象的关键机制。

对象(Object):

对象是内存中分配的一块区域,用于存储数据和执行操作。对象可以是实际的数据结构、实例化的类、数组等。 对象具有状态和行为,状态由对象的属性(字段、成员变量)表示,行为由对象的方法(函数)表示。 对象的生命周期通常从创建(实例化)开始,到不再需要时被销毁。在面向对象编程中,对象是程序中最基本的构建单元。

在许多编程语言中,特别是在面向对象的语言中,引用和对象之间的关系是密切的。引用用于操作对象,而对象则通过引用进行访问。当多个引用指向同一个对象时,它们共享对该对象的访问权限,对对象的修改将反映在所有引用上。

// 创建对象并获取引用
Person person1 = new Person("Alice");
Person person2 = person1; // 通过引用 person2 共享对同一对象的访问

// 修改对象的状态
person2.ChangeName("Bob");

// 输出对象的状态
Console.WriteLine(person1.GetName()); // 输出 "Bob"
在上面的示例中,person1  person2 都是对同一个 Person 对象的引用,它们共享对该对象的访问。
修改其中一个引用所指向对象的状态会影响其他引用。这反映了引用和对象之间的关系。

10.什么是匿名类,有什么好处?

简化代码,减少冗余:匿名类时不⽤定义、没有名字的类,使⽤⼀次便可丢弃。好处是简单、随意、临时的。

扩充

(来源:《C#文档》

匿名类型提供了一种方便的方法,可用来将一组只读属性封装到单个对象中,而无需首先显式定义一个类型。类型名由编译器生成,并且不能在源代码级使用。每个属性的类型由编译器推断。

var v = new { Amount = 108, Message = "Hello" };
Console.WriteLine(v.Amount + v.Message);

11.重写和重载的区别?

重写(Override)和重载(Overload)是面向对象编程中两个不同的概念,它们分别用于实现多态性和提供更多的方法选择。

重载涉及到相同名称的方法,但参数列表不同。重写涉及到基类和派生类之间的关系,基类中的虚方法在派生类中被重新实现。

重载是编译时的多态性,根据调用时提供的参数类型来确定调用哪个方法。重写是运行时的多态性,根据对象的实际类型来确定调用哪个方法。

重写(Override):重写指的是在派生类中实现一个与基类中的虚方法(使用 virtual 关键字声明的方法)具有相同签名的方法。重写允许派生类提供自己的实现,覆盖基类中的虚方法。重写的方法具有相同的名称、参数列表和返回类型,但必须使用 override 关键字。

12. C# 中有没有静态构造函数,如果有是做什么用的?

C# 中存在静态构造函数。

静态构造函数是类的一种特殊类型的构造函数,用于初始化静态成员和执行一次性的初始化操作。它使用 static 关键字声明,没有访问修饰符,并且不能带有参数。

静态构造函数在以下情况下使用:

  • 初始化静态成员:静态构造函数用于初始化类的静态成员。这些成员是类的所有实例共享的,只会在类加载时初始化一次。

  • 执行一次性的初始化操作:静态构造函数通常包含一些在类加载时只需要执行一次的初始化操作。这可以确保在类的第一个静态成员被访问之前进行初始化。

静态构造函数是在类第一次被使用之前自动调用的,而且只会被调用一次。如果没有显式提供静态构造函数,系统会提供一个默认的静态构造函数,它在类加载时执行默认的初始化操作。在多线程环境中,静态构造函数是线程安全的,由运行时负责确保它只会执行一次。

13.怎样理解静态变量?静态成员和⾮静态成员的区别?

静态变量是属于类而不是属于类的实例的变量。它使用 static 关键字声明,并且在整个应用程序域中只有一个副本。所有类的实例共享相同的静态变量。

静态变量通常用于存储在类级别上共享的数据,例如计数器、配置信息等。

14.属性能在接⼝中声明吗?

可以,不能有访问修饰符,不能初始化赋值。

15.在项⽬中为什么使⽤接⼝?接⼝的好处是什么?什么是⾯向接⼝开发?

在项目中使用接口有多方面的好处,包括提高代码的可扩展性、可维护性和可测试性。

以下是一些常见的原因和好处:

  • 解耦合:接口允许将抽象和实现分离,从而减少类之间的耦合。通过面向接口编程,可以更容易地替换具体的实现而不影响调用方的代码。
  • 可扩展性:接口提供了一种扩展现有功能的方式,而无需修改调用方的代码。新的实现可以实现相同的接口,并且可以被现有的调用方直接使用。

  • 代码复用:通过定义接口,可以在不同的类中共享相同的规范,从而提高代码的复用性。多个类可以实现相同的接口,使得它们具有相似的行为。

  • 多态性:接口支持多态性,允许在运行时使用基本接口类型引用实际类型的对象。这提高了代码的灵活性,使得可以动态选择不同的实现。

  • 易于测试:接口使得代码更容易进行单元测试。通过使用接口,可以轻松地为每个接口实现编写单元测试,并模拟不同的场景。

  • 降低依赖性:接口降低了类之间的直接依赖关系。调用方只需要知道接口的规范,而不需要了解具体实现的细节,从而减少了代码的依赖性。

  • 规范定义:接口提供了一种定义规范和契约的方式。通过接口,可以明确定义类应该具有的行为和属性,从而提高了代码的清晰度和可读性。

面向接口开发(Interface-Oriented Programming):

  • 面向接口开发是一种编程范式,强调在设计和实现中使用接口。这种方法推崇通过定义和实现接口来组织代码,以实现解耦合、可扩展性和代码复用的目标。在面向接口开发中,重视设计良好的接口,使得不同的组件可以通过接口进行通信,而不是直接依赖于具体的实现。

  • 通过面向接口开发,代码更容易进行维护和扩展,因为可以轻松替换实现而不影响其他部分的代码。这种方式也符合依赖倒置原则(Dependency Inversion Principle),即高层模块不应该依赖于低层模块的具体实现,而是应该依赖于抽象。

16. 什么时候用重载?什么时候用重写?

这个问题和第 9 题重复。

重载(Overload):

当你希望在同一个类中定义多个具有相同名称但参数列表不同的方法时,可以使用重载。参数列表的差异可以体现在参数的个数、类型或顺序上。

重写(Override):

当你有一个基类,并且在派生类中希望提供对基类中虚方法的新实现时,可以使用重写。重写要求在派生类中使用 override 关键字,确保方法签名和基类中的虚方法相同。

17. 静态方法可以访问非静态变量吗?如果不可以为什么?

在C#中,静态方法不能直接访问非静态变量。这是因为静态方法是与类关联的,而非静态变量是与类的实例关联的。

18. 在 .Net 中所有可序列化的类都被标记为_?

在.NET 中,所有可序列化的类都应该被标记为 [Serializable] 特性。该特性是 System.SerializableAttribute 类的别名,用于指示类可以进行序列化。

[Serializable]
public class MyClass
{
  // 类的成员和逻辑
}

通过标记类为 [Serializable],表明该类的实例可以被序列化,即可以将其转换为字节流,以便进行数据存储、网络传输或跨应用程序域的通信。在序列化的过程中,类的成员变量将被转换为可传输或可存储的格式。

不是所有的类都需要进行序列化。只有当需要在不同的应用程序域、进程或计算机之间传递对象实例时,或者需要将对象持久化到磁盘或数据库时,才需要考虑序列化。

19.C#中 property 与 attribute 的区别,他们各有什么⽤处,这种机制的好处在哪⾥?

⼀个是属性,⽤于存取类的字段,⼀个是特性,⽤来标识类,⽅法等的附加性质。

Property 用于定义类的结构和行为,而 Attribute 用于添加元数据信息,增加代码的可扩展性和灵活性。属性和特性在C#中分别服务于不同的目的,但它们都有助于提高代码的可读性、可维护性和可扩展性。

20. 当使用 new B() 创建 B 的实例时,产生什么输出?

class A
{
    public A()
    {
        PrintFields();
    }
    public virtual void PrintFields() { }
}
class B : A
{
    int x = 1;
    int y;
    public B()
    {
        y = -1;
    }
    public override void PrintFields()
    {
        Console.WriteLine("x={0},y={1}", x, y);
    }
}

输出:x=1,y=0

分析: 1)创建 B 类的实例 b 时,首先调用基类 A 的构造函数。 2)在 A 类的构造函数中,调用虚方法 PrintFields()。 3)由于 B 类重写了虚方法 PrintFields(),实际上调用的是 B 类中的方法。 4)在 B 类的 PrintFields() 方法中,输出了字段 x 和 y 的值,此时 x=1,y=0。 5)完成基类 A 的构造函数的调用。 6)调用 B 类的构造函数,在构造函数中将字段 y 重新赋值为 -1,但是由于此时没有再次调用 PrintFields() 方法,所以没有输出语句执行。

21. 能用 foreach 遍历访问的对象需要实现 接口或声明方法的类型

在C#中,foreach 循环用于迭代可枚举集合中的元素。对于一个对象能够被 foreach 遍历,需要满足以下两个条件之一:

1、实现 IEnumerable 接口

对象需要实现 IEnumerable 接口或其泛型版本 IEnumerable。这两个接口定义了用于枚举集合元素的 GetEnumerator 方法。

GetEnumerator 方法返回一个实现 IEnumerator 接口或其泛型版本 IEnumerator 的迭代器对象。

public class MyCollection : IEnumerable
{
  private int[] data = { 1, 2, 3, 4, 5 };
  public IEnumerator GetEnumerator()
  {
      return data.GetEnumerator();
  }
}

2、声明 GetEnumerator 方法

对象可以声明一个 GetEnumerator 方法,该方法返回一个实现 IEnumerator 接口或其泛型版本 IEnumerator 的迭代器对象。

public class MyCollection
{
  private int[] data = { 1, 2, 3, 4, 5 };
  public IEnumerator GetEnumerator()
  {
      return new MyIterator(data);
  }
}

public class MyIterator : IEnumerator
{
  private int[] data;
  private int index = -1;
  public MyIterator(int[] data)
  {
      this.data = data;
  }

  public object Current => data[index];
  public bool MoveNext()
  {
      index++;
      return index < data.Length;
  }

  public void Reset()
  {
      index = -1;
  }
}

在这两种情况下,对象都可以被 foreach 循环遍历。值得注意的是,实现 IEnumerable 接口或声明 GetEnumerator 方法时,需要提供一个实现 IEnumerator 接口的迭代器对象,以便正确进行元素的迭代。