C#事件的实现

4

我正在学习关于C#类中事件的实现。

我有一个示例案例:

有一个 Sport 和 City 类都继承自 Car 类。Car 类有一个基础方法 OnBuy,被 Sport 和 City 类继承。在 OnBuy 方法内,事件处理程序已被分配到 Buy 事件上。

还有一个名为 LicenseService 的服务或类,每次购买时都会生成许可证号码。

我在这种情况下实现了事件驱动编程。

这是我的 git 示例:

https://github.com/adityosnrost/CSharpLearningEvent

问题:

  1. 这是使用 C# 中事件的正确方式吗?

  2. 如果这是正确的,我可以将 OnBuy 方法覆盖到每个子类中吗?如果覆盖可用,我该怎么做?

  3. 如何从此示例中使其更好?

谢谢

class Program
{
    static void Main(string[] args)
    {
        Car car = new Car();
        Sport sport = new Sport();
        City city = new City();

        //car.Name();
        //sport.Name();
        //city.Name();

        //Console.ReadLine();

        LicenseService ls = new LicenseService();

        city.Buy += ls.GenerateLicense;

        city.OnBuy();

        Console.ReadLine();
    }
}

internal class Car
{
    internal virtual void Name()
    {
        Console.WriteLine("Car");
    }

    internal event EventHandler Buy;

    internal virtual void OnBuy()
    {
        EventHandler handler = Buy;
        if (null != handler)
        {
            handler(this, EventArgs.Empty);
        }
    }
}

internal class Sport: Car
{
    internal override void Name()
    {
        Console.WriteLine("Sport");
    }
}

internal class City: Car
{
    internal override void Name()
    {
        Console.WriteLine("City");
    }
}

internal class LicenseService
{
    internal void GenerateLicense(object sender, EventArgs args)
    {
        Random rnd = new Random();

        string carType = sender.GetType().ToString();

        string licenseNumber = "";

        for(int i = 0; i < 5; i++)
        {
            licenseNumber += rnd.Next(0, 9).ToString();
        }

        Console.WriteLine("{1} Car has been bought, this is the license number: {0}", licenseNumber, carType);
    } 
}

相关链接:https://dev59.com/8XE95IYBdhLWcg3wHqMV/ - bommelding
6个回答

5
如果我制作这个程序,我会做以下更改:
  • 事件Buy的签名

首先,它有两个参数Object和EventArgs,而在处理程序方法中,你只需要Car(以及下面讨论的Random)。

  • 我会在Child的构造函数中传递LicenseService,并在构造函数中注册(订阅)事件。 这将是更清晰的方式。
  • 我会在父类中创建一个字符串成员CarName,这样每个子类都可以在任何地方使用它。
  • 还有一件事,在这段代码中我没有做的是,我永远不会把事件命名为Buy,而是用Bought。
  • (这仅适用于此场景)在您的代码中,在GenerateLicense()内部,你每次创建一个新的Random对象。 因此,如果你对该方法的两个调用在短时间内,它们将生成相同的随机数。 为什么?请参阅此问题 - 或者你可以自己尝试下面的示例代码。 因此,我会在GenerateLicense()中传递已经创建的Random对象。 因此,Random将在该方法的每个调用中通用。

用于解释随机数行为的示例代码

        //as object of Random numbers are different,
        //they will generate same numbers
        Random r1 = new Random();
        for(int i = 0; i < 5; i++)
            Console.WriteLine(r1.Next(0, 9));
        Random r2 = new Random();
        for(int i = 0; i < 5; i++)
            Console.WriteLine(r2.Next(0, 9));

更新

  • 如评论区中Mrinal Kamboj建议的那样,我们不应该让Events暴露给外部代码。在本回答中也添加了他的评论。

有两个问题,首先EventHandler Buy不能被直接访问,因为任何人都可以将其设置为空并取消所有订阅。它需要一个事件访问器,以便可以使用+=-=运算符访问事件,并在那里使其对多个订阅者线程安全,否则会出现竞争条件,请查看简单示例

以下是代码:

你的类结构:

internal delegate void EventHandler(Car car, Random rnd);
internal class Car
{
    internal string CarName;
    internal virtual void SetName()
    {
        this.CarName = "car";
    }

    //Edit : As Mrinal Kamboj suggested in comments below
    //here keeping event Buy as private will prevent it to be used from external code
    private event EventHandler Buy;
    //while having EventAccessros internal (or public) will expose the way to subscribe/unsubscribe it
    internal event EventHandler BuyAccessor
    {
        add 
        {
            lock (this)
            {
                Buy += value;
            }
        }
        remove
        {
            lock (this)
            {
                Buy -= value;
            }
        }
    }

    internal virtual void OnBuy(Random rnd)
    {
        if (Buy != null)
            Buy(this, rnd);
    }
}

internal class Sport: Car
{
    LicenseService m_ls;
    internal Sport(LicenseService ls)
    {
        this.m_ls = ls;
        this.BuyAccessor += ls.GenerateLicense;
        SetName();
    }

    internal override void SetName()
    {
        this.CarName = "Sport";
    }
}

internal class City: Car
{
    LicenseService m_ls;
    internal City(LicenseService ls)
    {
        this.m_ls = ls;
        this.BuyAccessor += ls.GenerateLicense;
        SetName();
    }
    internal override void SetName()
    {
        this.CarName = "City";
    }
}

LicenseService

internal class LicenseService
{
    internal void GenerateLicense(Car sender, Random rnd)
    {
        string carName = sender.CarName;
        string licenseNumber = "";
        for(int i = 0; i < 5; i++)
        {
            licenseNumber += rnd.Next(0, 9).ToString();
        }
        Console.WriteLine("{1} Car has been bought, this is the license number: {0}", licenseNumber, carName);
    } 
}

并调用该流程

static void Main(string[] args)
{
    Random rnd = new Random();
    LicenseService ls = new LicenseService();
    Sport sport = new Sport(ls);
    City city = new City(ls);

    city.OnBuy(rnd);
    sport.OnBuy(rnd);

    Console.ReadLine();
}

1
当同一个Car对象被共享并且其中一个对象将事件设置为空时,其他订阅者甚至会失去他们的订阅(全部丢失)。因此,在哪里处理事件,绝不能直接访问事件对象,对于多线程客户端而言,Event不是线程安全的,这可能会导致损坏/竞争条件。 - Mrinal Kamboj
@MrinalKamboj,你现在可以检查一下吗?如果还需要更正,请告诉我。 - Amit
事件设计看起来很好,加上访问器后更完美。 - Mrinal Kamboj
1
@Amit 非常感谢您的精彩回复。关于委托,您被分配到EventHandler。根据我的理解,每个EventHandler类型将始终发送在此命名空间中设置为委托的Car对象类型。这是正确的吗?谢谢。 - Adityo Setyonugroho
1
@Amit 哇,非常感谢你。这对我来说是非常有帮助的信息,让我更好地了解它们。我会更多地练习在C#中实现这个功能。非常感谢你 :) - Adityo Setyonugroho
显示剩余11条评论

3

这是一个比较长的回答,但我会先回答你的例子,然后再给出一些通用的技巧。请注意所有的代码都是伪代码,需要进行语法调整才能编译。

首先,你的逻辑结构没有意义,这就是为什么你可能难以确定它是否正确的原因。

例如,在现实世界中,你不会直接联系汽车来购买它,而是联系销售汽车的商店或服务。你只有在驾驶汽车或使用其它功能时才会直接操作汽车提供的功能。汽车不会自行分配许可证。最后,如果你采用基本的卖方/买方例子,购买是一个通常呈线性过程的过程(可以由方法表达,而不需要触发器)。因此,当你调用shop.BuyCar(sportsCar)时,所有的购买逻辑都可以从购买方法中调用。

Class Shop{ 

    public Car BuyCar( carWithoutLicense ){
        //Purchase logic
        LicenseService.AssignLicense( carWithoutLicense ).
        return carWithoutLicense.
    }
}
//A person would call this method, no the car

一个更好的正确利用事件的例子是汽车前面板上的警示灯,因为它存在是为了通知驾驶员他/她可能想要做出反应的事情。例如:一个检查引擎灯。
class Car {
   Bool CheckEngingLightIsOn = false;
   public void Ride( ... ){
    if( enginge.faultDetected == true ){
       TurnOnCheckEngineAlert( );
    }
   }
   public void TurnOnCheckEngineAlert( ){
      CheckEngingLightIsOn = true;
      if( CheckEngineLightSwitch != null ){
         CheckEngineLightSwitch.Invoke( ... )
      }
   }
}

class Driver {
   public Driver( Car car ){
      this.car = car;
      if( driverState != Drunk ){
       car.CheckEngineLightSwitch = TakeAction;
      }
   }
   public Drive( ){
      car.Ride( );
   }
   public void TakeAction( Car car, EventArgs e ){
      //get out, open the hood, check the engine...
      if( car.CheckEngingLightIsOn == true ){ "Light turned on
        //Check Engine
      }else{
        //continue driving
      }
   }
}

不需要深入抽象,注意事件链:
- 驾驶员开车,不必担心其他事情(如检查发动机灯),直到它们发生。 - 如果汽车检测到故障,它会打开检查发动机灯,并且有事件处理程序(订阅者)触发该事件。 - 事件被触发,但是驾驶员必须订阅它才能注意到变化。 - 只有当驾驶员订阅了该事件(在这种情况下,如果没有喝醉),他将根据该事件采取行动。
这个例子与您的示例基本不同,因为:
- 在驾驶汽车时,驾驶员不需要一直关注检查发动机灯(即使他可以检查)。 - 检查发动机状态并在发动机灯上表示它是汽车的标准过程。 - 驾驶员和汽车都会影响彼此的进一步行动,如果表达线性逻辑,则此逻辑效率低下。
换句话说,购买流程已经确定(支付、许可证、货物转移),不能跳过必要的步骤。汽车移动自身的过程并非固定不变,因为汽车和驾驶员都不知道旅途中会发生什么。例如,如果没有故障或者驾驶员没有注意到灯,驾驶员可能会直接开车到目的地,或者汽车可能会在驾驶员注意到检查发动机灯时强制驾驶员停车(实质上,它们都在某种程度上互相控制)。
关于您的用例的一些通用提示:
在您的示例中,您已经做了一个有点复杂的用例(逻辑放置不正确),它将运行,但是结构上不正确(不良的逻辑结构往往会导致在设计进一步逻辑时出现人为错误)。
首先,您应该查看每个对象相关的逻辑是什么。事件/方法是否代表您的对象执行的功能(即对象自己执行的功能)或影响您的对象但对象本身在过程中不执行任何操作的东西?例如:汽车自己“行驶”(即使这个过程的开始和所有参数,如速度或方向由驾驶员控制);将许可证分配给汽车完全在汽车结构之外进行,汽车只在过程中获得属性更改(许可证)。
这种区别很重要,因为只有由您的对象执行的逻辑才与该对象相关,并且扩展而言,只有由另一个对象执行并仅影响您的对象的逻辑是无关紧要的,应该放在其他地方。因此,Buy绝对不属于汽车,而Ride(移动过程)属于汽车。
其次,你的命名将有助于更好地理解这个主题。方法代表动作,应该以此命名(Shop.BuyCarCar.RideDriver.Drive),事件代表反应触发器Car.CheckEngineLightSwitch),事件处理程序代表对动作的反应(反应仍然是一个动作,因此不需要特殊命名,但您可以命名以区分动作和反应)。

非常感谢您详细的解释和实际应用方面的细节。我学到了很多,并且理解了这种情况在日常基础上的实现顺序。您提供的精彩技巧将帮助我使示例更易于理解。 - Adityo Setyonugroho
因为你的解释真正关心良好的面向对象类设计,所以我点了赞。在企业/现实世界中快速开发代码的标志是采用捷径,例如在你打开代码文件的类中编写一个函数,但该函数主要操作其他类,违反了封装的基本原则,并给你带来了巨大的技术债务,形成可怕的代码耦合。感谢你提供完整而雄辩的答案,即使对于这样一个简单而几乎显然是作业类型的问题也是如此。 - dodexahedron

1

我建议您将许可服务注入到汽车中,并在调用购买功能时调用生成许可证函数。

using System;

namespace ConsoleApp1.Test
{

    class Program
    {
        static void Maintest(string[] args)
        {
            ILicenseService licenseService = new LicenseService();

            Sport sport = new Sport(licenseService);
            City city = new City(licenseService);

            //car.Name();
            //sport.Name();
            //city.Name();

            //Console.ReadLine();            

            city.OnBuy();

            Console.ReadLine();
        }
    }

    internal abstract class Car
    {
        protected readonly ILicenseService licenseService;

        public Car(ILicenseService _licenseService)
        {
            licenseService = _licenseService;
        }

        internal virtual void Name()
        {
            Console.WriteLine("Car");
        }

        internal event EventHandler Buy;

        internal virtual void OnBuy()
        {
            // TODO
        }
    }

    internal class Sport : Car
    {
        public Sport(ILicenseService _licenseService) : base(_licenseService)
        {

        }

        internal override void Name()
        {
            Console.WriteLine("Sport");
        }

        internal override void OnBuy()
        {
            licenseService.GenerateLicense(new object());
        }
    }

    internal class City : Car
    {
        public City(ILicenseService _licenseService) : base(_licenseService)
        {

        }

        internal override void Name()
        {
            Console.WriteLine("City");
        }

        internal override void OnBuy()
        {
            licenseService.GenerateLicense(new object());
        }
    }

    internal interface ILicenseService
    {
        void GenerateLicense(object param);
    }

    internal class LicenseService : ILicenseService
    {
        public void GenerateLicense(object param)
        {
            // do your stuff
        }
    }

}

1
本帖的主要目的是OP正在尝试以正确的方式实现事件,而您已经消除了事件的全部使用。 - Amit
1
@Amit 实际上,OP在当前情况下不需要事件,可以设计出没有事件的方案。 - programtreasures
这就是为什么超级简单的事件模式示例不适合作为学习样本的一个原因。互联网上的大多数示例(即使在SO上)实际上可能只是函数调用,而大多数有抱负的软件工程师都能看穿这一点。难点在于传授控制反转的概念,这实际上是事件提供的东西,它是一种一流、类型安全、语言支持的构造,非常适合使用发布-订阅范式。 - dodexahedron
1
@dodexahedron 正确..!! 这种超级简单的例子无法想象事物。当考虑事件驱动或发布-订阅范式时,我们会考虑观察者模式。 - programtreasures
许多在对象/集合上进行热循环或轮询的应用程序将受益于使用事件模式。所有应用程序都可以分解为某种状态机。如果该状态机可以在整个系统的每个状态更改时仅运行一次(当然,线程使这更有趣),那就是处理事情的最有效方式。除非状态已更改,否则没有人关心任何事情,因此所有其他方法都是愚蠢的。一旦开发人员理解了这个概念,他们就会立即升级。游戏/UI的渲染循环不算,因为它们必须是循环的。 - dodexahedron
作为一个超出字符限制的补充,我个人不喜欢事件委托必须是EventHandler类或遵循public void foo(object sender, EventArgs e)模式的设计指南。这有点降低了特定类型安全委托为事件所带来的实用性和静态分析优势。当然,这样做更容易且在库之间非常标准化,但这并不是一种具有类型安全和智能感知等功能的语言/环境的借口。如果我们遵循这个指南,它实际上往往会导致多出几行代码。 - dodexahedron

1

以下是我偏爱的设计:

class Program
{
    static void Main(string[] args)
    {
        Car car = new Sport();
        car.BuyEvent += License.GenerateLicense;        
        car.OnBuy();
        car = new City();
        car.BuyEvent += License.GenerateLicense;
        car.OnBuy();
    }
}

internal abstract class Car
{
    internal abstract void Name();

    protected abstract event EventHandler Buy;

    public abstract event EventHandler BuyEvent;

    public abstract void OnBuy();   
}

internal class Sport : Car
{
    internal override void Name()
    {
        Console.WriteLine("Sport Car");
    }

    protected override event EventHandler Buy;

    public override event EventHandler BuyEvent
    {
        add
        {
            lock (this)
            {
                Buy += value;
            }
        }

        remove
        {
            lock (this)
            {
                Buy -= value;
            }
        }
    }

    public override void OnBuy()
    {
        if (Buy != null)
        {
            Buy(this, EventArgs.Empty);
        }
    }

}

internal class City : Car
{
    internal override void Name()
    {
        Console.WriteLine("City Car");
    }

    protected override event EventHandler Buy;

    public override event EventHandler BuyEvent
    {
        add
        {
            lock (this)
            {
                Buy += value;
            }
        }

        remove
        {
            lock (this)
            {
                Buy -= value;
            }
        }
    }

    public override void OnBuy()
    {
        if (Buy != null)
        {
            Buy(this, EventArgs.Empty);
        }
    }
}

internal static class License
{

    public static void GenerateLicense(object sender, EventArgs args)
    {
        Random rnd = new Random();

        string carType = sender.GetType().Name;

        string licenseNumber = "";

        for (int i = 0; i < 5; i++)
        {
            licenseNumber += rnd.Next(0, 9).ToString();
        }

        Console.WriteLine($"{carType} Car has been bought, this is the license number: {licenseNumber}");
    }

}

重要提示:

  1. Car 基类设置为抽象类,只能派生和像 City / Sport Car 一样使用
  2. 在每个子类中添加 event add / remove accessor 以进行自定义事件处理。使访问器对共享客户端访问线程安全
  3. License - GenerateLicense 设为静态的,因为该类中没有状态/数据,或者将其与 City / Sport class 实现集成,通过另一个基本抽象方法
  4. 在运行时将抽象类 Car 注入 Sport \ City 对象,并通过基类 Car 对象用于事件订阅

非常感谢您的解决方案。如果可以的话,我想问一些问题。我想更多地了解如何使用“EventArgs args”参数。有些教程和示例根本没有更改此参数。它到底是什么?我们可以用它做哪些参数?谢谢 - Adityo Setyonugroho
@AdityoSetyonugroho 你可以将任何类型的对象(甚至是原始类型,如int、string)作为对象传递(这里是事件的第一个参数),更有趣的部分是第二个参数,它是System.EventArgs类型。通常,如果事件需要传递它,但你没有特定要发送的内容(因为你已经通过第一个参数发送了正确的信息),你可以简单地传递EventArgs.Empty,但为了更有效地使用它,你可以创建一个继承自EventArgs类的类,然后将该类的对象作为第二个参数传递。 - Amit
@AdityoSetyonugroho 这可以很简单地理解。您需要在第二个参数中传递 EventArgs 对象,因为我们可以轻松地将一个对象子类强制转换为父类类型。任何继承 EventArgs 类的类,我们都可以传递该类的对象。因此,根据要求,您需要在事件中传递(侦听器将作为参数)一个类,并且该类必须继承 EventArgs。因此,在该对象中加载信息并将其传递到事件中。请参阅此文章 - Amit
@Amit 非常感谢您对 EventArgs 的详细解释。所以,它就像是每次触发事件时发送默认值一样,对吧?继承 EventArgs 的类只是保存每次调用事件时需要的值。太神奇了,这么多东西可以在事件实现中使用。 - Adityo Setyonugroho
另外,rnd.Next(0, 9) 是一个非常小的范围,当快速调用且不更新 Random 类对象时,它将不可避免地重复。 - Mrinal Kamboj
显示剩余4条评论

1

不行。

除了拥有事件的类(或者如果你知道自己在做什么,继承自拥有事件的类的类 - 即使是这样,他们也应该注意原始实现以避免微妙的问题),其他人不应该调用事件。

基本上,订阅事件是一个承诺,即当发生某个事情时,您将调用从订阅者那里提供给它的函数。事件本身只是代码构造,使您可以进行订阅,而无需知道或实现多路传输函数指针调用的复杂性。

否则,您只是调用一个函数,可能不需要使用事件。

事件本质上是有意的代码注入-它允许您使一些其他类在执行某些操作时执行您编写的任意代码。


在 OP 的设计中,事件也在 OnBuy() 内部调用,它只是为了示例而创建并公开。此外,事件不是任意代码,而是基于发布者-订阅者模式的函数指针,在这个模式中,暴露事件的类发布某些东西,订阅者消费它并在相关方法中进行处理。 - Mrinal Kamboj
函数指针的目的是能够传递任意代码的执行。不太确定这个评论的目的是什么。"任意"并不意味着愚蠢、无结构或随机。它只是意味着...无论你想传递什么,只要它与委托的类型安全签名匹配(这就是它们与函数指针不同的地方,后者非常不安全)。 - dodexahedron

1

1) 这是在C#中使用Event的正确方式吗?

不完全正确。OnBuy应该是一个受保护的虚拟方法。这也排除了你从Main()方法中调用它。

更常见的做法是调用someCar.Buy(),然后Buy()会触发OnBuy()。

2) 如果这是正确的方法。我能否将OnBuy方法覆盖到每个子级中?如果可以覆盖,我该怎么做?

是的,你可以覆盖它。把它看作是订阅自己的一种更有效的方式(这将是替代方案)。

当特定类型的汽车被购买时,你可以执行任何必要的操作。但一定要调用base.OnBuy()

3) 我该怎么做才能使这个示例变得更好?

CreateLicense听起来并不像一个事件的好选择,它更像是由CarDealer调用的业务规则。

按照现代设计规则,汽车将成为一个相对被动的实体对象(贫血领域模型)。

事件通常用于告诉其他组件“我已经改变,请执行你的任务”,而不是对自身执行重要操作。


没有必要将 OnBuy 设为 protected,这只是一个虚拟方法,用于在内部调用事件。实际上,这将基于某些逻辑进行内部调用,并且最好将事件对象设置为私有,因此子类中的 OnBuy 只能执行 base.OnBuy(),这几乎没有什么用处。 - Mrinal Kamboj
1
设计指南非常注重保护性。原因很简单:封装。 - bommelding
哪些设计准则只有在将Buy事件实现放在基类之外时才能完成,以便子类可以随意访问该事件,而不是依赖于base.OnBuy。顺便说一句,我没有投反对票,我不会因为理解上的差异而投反对票。 - Mrinal Kamboj
2
呃... .Net设计准则?事件调用函数建议始终为受保护或私有的(通常为受保护的,除非它是密封类,以允许在继承中减少痛苦)。这不是什么秘密,“.net event design pattern” 的谷歌(或必应,因为毕竟这是.net)搜索返回了大量结果,这使得这个问题成为一个非常明显的观点。对技术理解上的差异并不能为使用不当而辩解,这等同于打破封装或冗余的函数调用。事件是一个特定且明确定义的解决方案,适用范围广泛,但是需要注意正确使用。 - dodexahedron

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接