C# 级联派生 类 与派生属性

C# Cascading Derived Classes with Derived Properties

所以我正在设计一个包含多个 classes 的框架,它们以级联的方式相互派生,以执行越来越多的特定任务。每个 class 也有自己的设置,这是他们自己的 classes。但是,这些设置应该与 classes 本身具有平行继承关系。对于这个问题的性质,设置 必须 与 class 本身分开,因为它们具有特定于我的框架的约束,这一点很重要。 (编辑: 我也想避免使用设置的组合模式,因为它使我正在使用的框架中的工作流程变得非常复杂。所以设置必须是单一的 objects,而不是多个 object 的组合。)也就是说,我认为这有点像两个平行的 class 层次结构。一个相关的例子可能是这样的:

假设您有一个 Vehicle class,以及一个随附的 class、VehicleSettings,用于存储每辆车应具有的相关设置,例如 topSpeedacceleration.

然后假设您现在有一个 Car 和一个 Airplane class,它们都继承自 Vehicle class。但现在您还创建了 CarSettings,它继承自 VehicleSettings class 但添加了成员​​ gear。然后还有AirplaneSettings,继承自VehicleSettings,但是增加了成员drag.

为了展示我如何继续这种模式,假设现在我有一个新的 class SportsCar,它继承自 Car。我创建了相应的设置 SportsCarSettings,它继承自 CarSettings 并添加了一个成员 sportMode.

设置这样的 class 层次结构的最佳方法是什么,这样我也可以考虑派生的 classes 的设置?

理想情况下,我希望这样的示例能够像这样工作:

public class VehicleSettings
{
    public float topSpeed;
    public float acceleration;
    // I am explicitly not adding a constructor as these settings get initialized and
    // modified by another object
}

public class Vehicle
{
    public float speed = 0;
    protected VehicleSettings settings;
    
    // Similarly here, there is no constructor since it is necessary for my purposes 
    // to have another object assign and change the settings
    
    protected virtual float SpeedEquation(float dt)
    {
        return settings.acceleration * dt;
    }
    
    public virtual void UpdateSpeed(float dt)
    {
        speed += SpeedEquation(dt)
        if(speed > settings.topSpeed)
        {
            speed = settings.topSpeed;
        }
    }
}

public class CarSettings : VehicleSettings
{
    public int gear;
}
    
public class Car : Vehicle
{
    // Won't compile
    public CarSettings settings;
    
    // Example of the derived class needing to use
    // its own settings in an override of a parent
    // method
    protected override float SpeedEquation(float dt)
    {
        return base.SpeedEquation(dt) * settings.gear;
    }
}

public class AirplaneSettings : VehicleSettings
{
    public float drag;
}
    
public class Airplane : Vehicle
{
    // Won't compile
    public Airplane settings;
    
    // Another example of the derived class needing to use
    // its own settings in an override of a parent
    // method
    public override void UpdateSpeed(float dt)
    {
        base.UpdateSpeed(dt) 
        speed -= settings.drag * dt;
    }
}

public class SportsCarSettings : CarSettings
{
    public bool sportMode;
}
    
public class SportsCar : Car
{
    // Won't compile
    public SportsCarSettings settings;
    
    // Here is again an example of a further derived class needing
    // to use its own settings to override a parent method
    // This example is here to negate the notion of using generic
    // types only once and not in the more complicated way mentioned
    // below
    public override float SpeedEquation(float dt)
    {
        return (settings.acceleration + (settings.sportMode ? 2 : 1)) * dt;
    }
}

寻找一些可能的解决方案

  1. 使用通用类型。例如,我可以让它成为 public class Vehicle<T> : where T : VehicleSettings 我可以让它有行 T settings 这样当 CarAirplane 继承自 Vehicle 他们可以这样做: public Car<T> : Vehicle<T> where T : CarSettingspublic Airplane<T> : Vehicle<T> where T : AirplaneSettings,等等。 如果我想在不插入通用类型的情况下实例化,例如 Car,这确实会使事情变得有些复杂。因为那时我将不得不创建 Car<T> 的 child class Car,如下所示:public class Car : Car<CarSettings> { }。而且我必须对每个派生类型做类似的事情。

  2. 在必要的方法中使用类型转换。例如,我可以修改 Car class 如下:

     public class Car : Vehicle
     {
         // Don't reassign settings and instead leave them
         // as VehicleSettings
    
         // Cast settings to CarSettings and store that copy
         // locally for use in the method
         protected override float SpeedEquation(float dt)
         {
             CarSettings settings = (CarSettings)this.settings;
             return base.SpeedEquation(dt) * settings.gear;
         }
     }
    
  3. 我还看到了一个使用属性的建议,如 示例中所示,但这看起来很笨拙,主要是因为它似乎并没有真正解决问题。即使从 属性 返回动态类型,您仍然必须将返回值转换为您想要的类型。如果这是解决问题的正确方法,我将不胜感激关于如何正确实施该方法的解释。

  4. 可能可以使用 new 关键字来隐藏 parent class 版本的 settings 并将其替换为对应 child class 设置类型的名为 settings 的新变量,但我认为通常不建议这样做,主要是为了使与原始 parent 的关系复杂化class 的 'settings',影响继承方法中该成员的范围。

所以我的问题是哪个是这个问题的最佳解决方案?它是这些方法中的一种,还是我在 C# 语法中遗漏了一些非常重要的东西?

编辑:

既然有人提到了 Curiously Recurring Template Pattern 或 CRTP,我想谈谈我认为它有何不同。

在 CRTP 中,您有类似 Class<T> : where T : Class<T> 的内容。或者类似地,您可能会遇到类似 Derived<T> : T where T : Base<Derived>.

的情况

然而,这更多的是关于 class 需要与与其自身类型相同的 object 交互或派生的 class 需要与之交互需要与派生的 class object 进行交互的基础 class object。 无论哪种方式,那里的关系都是循环的。

这里,关系是平行的。这并不是说 Car 会与 Car 交互,也不是说 SportsCar 会与 Vehicle 交互。就是 Car 需要有 Car 设置。 SportsCar 需要有 SportsCar 设置,但这些设置只会随着继承树的向上移动而略有变化。所以我认为,如果像 C# 这样深度面向对象的语言需要跳过这么多环节来支持“并行继承”,或者换句话说,不仅仅是 object 本身“进化”,我认为这似乎有点荒谬" 在他们从 parent 到 child 的关系中,而且成员本身也是如此。

当您没有强类型语言时,例如 Python,您可以免费获得这个概念,因为对于我们的示例,只要我分配给任何特定实例的设置object 具有相关属性,它的 tpe 将无关紧要。所以我想更多的是,有时强类型范例可能是一个障碍,因为我希望 object 由其可访问属性定义,而不是在这种情况下使用设置定义它的类型。 C# 中的强类型系统迫使我制作模板的模板或其他一些奇怪的结构来解决这个问题。

编辑 2:

我发现选项 1 存在实质性问题。假设我想制作一个车辆列表和一个设置列表。假设我有一个 Vehicle、一个 Car、一个 Airplane 和一个 SportsCar,我想将它们放入一个列表中,然后遍历车辆和设置列表并将每个设置分配给其各自的车辆。设置可以放在 VehicleSettings 类型的列表中,但是自从 parent class VehicleVehicle<VehicleSettings>,Car 的 parent class 是 Car<CarSettings>,等等。因此,泛型失去的是干净的 parent child 层次结构使得将相似的 object 分组到列表、词典等中变得如此舒适。

但是,对于选项 2,这是可能的。如果无法避免上述问题,选项 2 尽管在某些方面不舒服,但似乎是最易于管理的方法。

对于我提出的解决方案,我们将使用强制转换,这使其与解决方案 2 类似。但我们只会将它们包含在属性中(而不是像解决方案 3 那样的属性)。我们还将像解决方案 4 中那样使用 new 关键字。

我避免了使用泛型的解决方案(解决方案 1),因为问题的“编辑 2”下描述的问题。但是,我想提一下,你可以有 class Vehicle<T> : Vehicle where T : VehicleSettings,我相信这就是 Charlieface 在评论中的意思。根据回到解决方案 2,好吧,如果你愿意,你可以像我在这个答案中做的那样加上泛型。


建议的解决方案

这是我提出的解决方案:

class VehicleSettings{}

class Vehicle
{
    private VehicleSettings _settings;

    public VehicleSettings Settings{get => _settings; set => SetSettings(value);}
    
    protected virtual void SetSettings(VehicleSettings settings)
    {
        _settings = settings;
    }
}

注意,我正在创建一个 private 字段。这个很重要。它允许我们控制对它的访问。我们将控制阅读方式和书写方式。

我们只会在Settings getter:

中阅读
public VehicleSettings Settings{get => _settings; set => SetSettings(value);}

如果您认为 _settings 可能包含派生类型,那么从该派生类型到基类型是没有问题的。因此,我们不需要派生的 classes 来扰乱那部分。

另一方面,我们只写SetSettings方法:

    protected virtual void SetSettings(VehicleSettings settings)
    {
        _settings = settings;
    }

这个方法是virtual。如果其他代码试图设置错误的设置,这将允许我们抛出异常。例如,如果我们有一个从 Vehicle 派生的 class,比如 Car,但它存储为 Vehicle 类型的变量,我们尝试写一个 VehicleSettingsSettings... 编译器无法阻止这种情况。据编译器所知,类型是正确的。我们能做的最好的是 throw.

请记住,这些必须是唯一使用 _settings 的地方。因此,任何其他方法都必须使用 属性:

class VehicleSettings
{
    public float Acceleration;
}

class Vehicle
{
    private VehicleSettings _settings;

    public VehicleSettings Settings{get => _settings; set => OnSetSettings(value);}
    
    protected virtual void OnSetSettings(VehicleSettings settings)
    {
        _settings = settings;
    }
    
    public virtual float SpeedEquation(float dt)
    {
        return Settings.Acceleration * dt;
    }
}

显然,这将扩展到派生的 classes。这就是您实施它们的方式:

class CarSettings : VehicleSettings
{
    public int Gear;
}

class Car : Vehicle
{   
    public new CarSettings Settings
    {
        get => (CarSettings)base.Settings; // should not throw
        set => base.Settings = value;
    }
    
    protected override void OnSetSettings(VehicleSettings settings)
    {
        Settings = (CarSettings)settings; // can throw
    }
    
    public override float SpeedEquation(float dt)
    {
        return base.SpeedEquation(dt) * Settings.Gear;
    }
}

现在,我添加了一个具有正确类型的新 Settings 属性。这样,使用它的代码就会得到它。但是,我们不想添加新的支持字段。所以这个 属性 将简单地委托给基础 属性。 那应该是我们访问基地的唯一地方属性。

我们也知道基础 class 会调用 OnSetSettings,所以我们需要覆盖它。 OnSetSettings 确保类型正确很重要。 并且通过在基类型中使用新的 属性 而不是 属性,编译器提醒我们进行转换。

请注意,如果类型错误,这些转换将导致异常。但是,只要 OnSetSettings 正确并且您不在其他任何地方写入 _settings,getter 就不应抛出。 你甚至可以考虑把getter写成base.Settings as CarSettings

此外,您只需要这两个演员表。其余代码可以并且应该使用 属性.

第三代class?当然。相同的模式:

class SportsCarSettings : CarSettings
{
    public bool SportMode;
}

class SportsCar : Car
{
    public new SportsCarSettings Settings
    {
        get => (SportsCarSettings)base.Settings;
        set => base.Settings = value;
    }
    
    protected override void OnSetSettings(VehicleSettings settings)
    {
        Settings = (SportsCarSettings)settings;
    }
    
    public override float SpeedEquation(float dt)
    {
        return (Settings.Acceleration + (Settings.SportMode ? 2 : 1)) * dt;
    }
}

如您所见,此解决方案的一个缺点是它需要一定程度的额外纪律。开发者一定要记住,除了 Settings 的 getter 和方法 SetSetting 之外,不要访问 _settings。同样不要访问 Settings 属性 Settings 属性 之外的基 Settings 属性 (在派生 class 中)。

考虑使用代码生成来实现模式。要么是 T4,要么是新发明的 Source Generators。


如果我们不需要setters

需要写设置吗?正如我之前解释的,setter 可能会抛出异常。如果你有一个 Vehicle 类型的变量,它有一个 Car 类型的对象,并尝试将 VehiculeSettings 设置为它(或其他东西,如 SportCarSettings)。但是,getter 不会抛出。

在 C# 9.0 中,有 return 虚方法(实际上是属性)的类型协变。所以你可以这样做:

class VehicleSettings {}

class CarSettings: VehicleSettings {}

class Vehicle
{
    public virtual VehicleSettings Settings
    {
        get;
        //set; // won't compile
    }
}

class Car: Vehicle
{
    public override CarSettings Settings
    {
        get;
        //set; // won't compile
    }
}

但是如果你添加 setters,那将无法编译。啊,但是你可以很容易地添加一个“SetSettings”方法。它会使用法有些倒退,但实施起来更容易:

class VehicleSettings {}

class Vehicle
{
    private VehicleSettings _settings;
    
    public virtual VehicleSettings Settings => _settings;
    
    public void SetSettings(VehicleSettings Settings)
    {
        OnSetSettings(Settings);
    }
    
    protected virtual void OnSetSettings(VehicleSettings settings)
    {
        _settings = settings;
    }
}

与提议的解决方案类似,我们将限制对支持字段的访问。不同之处在于现在 属性 是虚拟的并且没有 setter (因此我们可以用适当的类型覆盖它,而不是使用 new 关键字),而是我们有一个SetSettings 方法。

class CarSettings: VehicleSettings {}

class Car: Vehicle
{
    public override CarSettings Settings{get;}
    
    public void SetSettings(CarSettings settings)
    {
        base.OnSetSettings(settings);
    }

    protected override void OnSetSettings(VehicleSettings settings)
    {
        SetSettings((CarSettings)settings);
    }
}

另请注意,我们不必在方法上使用关键字 new。相反,我们正在添加一个新的重载。 就像提议的解决方案一样,这个解决方案需要纪律。它的缺点是不强制转换不是编译错误,它只是调用重载(导致堆栈溢出,我不是在说这个网站)。


如果我们可以有构造器

构造函数是禁忌吗?因为你可以这样做:

class VehicleSettings {}

class Vehicle
{
    protected VehicleSettings SettingsField = new VehicleSettings();

    public VehicleSettings Settings
    {
        get => SettingsField;
        set
        {
            if (SettingsField.GetType() == value.GetType())
            {
                SettingsField = value;
                return;
            }

            throw new ArgumentException();            
        }
    }
}

并导出class:

class CarSettings: VehicleSettings {}

class Car: Vehicle
{
    public Car()
    {
        SettingsField = new CarSettings();
    }

    public new CarSettings Settings
    {
        get => (CarSettings)base.Settings;
        set => base.Settings = value;
    }
}

此处,setter 强制 Settings 的新值必须与其当前具有的类型相同。这当然会带来一个问题:我们需要用有效类型初始化它,因此我们需要一个构造函数。 为了方便,我还是加了一个新的属性。

一个重要的优点是没有什么是虚拟的。所以我们不需要担心正确覆盖和覆盖。 我们还需要记得初始化。


我考虑过使用 IsAssignableFromIsInstanceOfType,但这样可以缩小类型。也就是说,它允许将 SportsCarSettings 分配给 Car,但是您将无法再分配 CarSettings

解决方法是存储类型...从构造函数(或自定义属性)设置它。因此,您可以声明 protected Type SettingsType = typeof(VehicleSettings); 从派生的 class 构造函数中使用 SettingsType = typeof(CarSettings); 设置它,并使用 SettingsType.IsAssignableFrom(value?.GetType()).

进行比较

现在,如果你问我,那是很多重复。


如果我们能有反思

看这段代码:

class CarSettings: VehicleSettings {}

class Car: Vehicle
{
    public new CarSettings Settings
    {
        get => (CarSettings)base.Settings;
        set => base.Settings = value;
    }
}

可以吗?反思!通过声明这个 属性 我们已经指定了基 class 应该测试的类型。

基地class是这个生物:

class VehicleSettings {}

class Vehicle
{
    protected VehicleSettings? SettingsField;
    private Type? SettingsType;

    public VehicleSettings Settings
    {
        get => SettingsField ??= new VehicleSettings();
        set
        {
            SettingsType ??= this.GetType().GetProperties()
                .First(p => p.DeclaringType == this.GetType() && p.Name == nameof(Settings))
                .PropertyType;
            if (SettingsType.IsAssignableFrom(value?.GetType()))
            {
                SettingsField = value;
                return;
            }

            throw new ArgumentException();            
        }
    }
}

这次我选择了惰性初始化,所以没有构造函数。但是,是的,该反射在构造函数中有效。它通过反射获取在派生类型上声明的 属性 的类型,并检查我们尝试设置的值是否匹配。

我必须使用 GetProperties 因为 GetProperty 会根据绑定标志给我 System.Reflection.AmbiguousMatchExceptionnull

结果是忘记在派生的 class 中声明 Settings 意味着您将无法设置 属性(它会抛出,因为它确实找到了属性).

而且,是的,这将延续到子孙后代。只是不要忘记声明一个新的 Settings:

class SportsCarSettings : CarSettings{}

class SportsCar: Car
{
    public new SportsCarSettings Settings
    {
        get => (SportsCarSettings)base.Settings;
        set => base.Settings = value;
    }
}