我一直在学习《 Head First C#》一书,这是第7章的练习,它涉及接口和抽象类。

请注意,班级设计是作者要求的,我愿意提出改进的建议。

我想对所有方面进行回顾。我的代码最终与本书的解决方案有很大不同,因为它已有几年的历史了,我认为“阻止”了最新的C#/。NET功能和语法。

在共享代码库中,我会包含该XML文档,但是由于这只是用于学习的本地代码,因此没有。感谢所有反馈。


作者发布的实现在GitHub上。这个想法是要实现一个可以进行探索的平面图,其中包括房间连接和通往外部的门。这是通过WinForms应用程序完成的。

这是本书中计划的图片。抱歉,找不到屏幕截图。



应用程序如下所示:




Location.cs

using System;

namespace House
{
    abstract class Location
    {
        public Location(string name) => Name = name;

        public string Name { get; private set; }
        public Location[] Exits;

        public virtual string Description
        {
            get
            {
                string description = $"You're standing in the {Name}. You see exits to the following places: \r\n";
                foreach (Location exit in Exits)
                {
                    description += $"— {exit.Name}";
                    if (exit != Exits[Exits.Length - 1])
                    {
                        description += "\r\n";
                    }
                }
                return description;
            }
        }
    }
}


Room.cs

using System;

namespace House
{
    class Room : Location
    {
        public Room(string name, string decoration) 
            : base(name)
        {
            Decoration = decoration;
        }

        private string Decoration { get; set; }

        public override string Description => $"You see {Decoration}. {base.Description} ";
    }
}


Outside.cs

using System;

namespace House
{
    class Outside : Location
    {
        public Outside(string name, bool hot)
            : base(name)
        {
            Hot = hot;
        }

        private bool Hot { get; }

        override public string Description => Hot 
            ? "It's very hot here. " + base.Description 
            : base.Description;
    }
}



IHasInteriorDoor.cs

using System;

namespace House
{
    interface IHasExteriorDoor
    {
        string DoorDescription { get; }
        Location DoorLocation { get; }
    }
}



OutsideWithDoor.cs

using System;

namespace House
{
    class OutsideWithDoor : Outside, IHasExteriorDoor
    {
        public OutsideWithDoor(string name, bool hot, string doorDescription)
            : base(name, hot)
        {
            DoorDescription = doorDescription;
        }

        public string DoorDescription { get; private set; }

        public Location DoorLocation { get; set; }

        public override string Description => $"{base.Description}\r\n You see {DoorDescription} to go inside.";
    }
}


RoomWithDoor.cs

using System;

namespace House
{
    class RoomWithDoor : Room, IHasExteriorDoor
    {
        public RoomWithDoor(string name, string decoration, string doorDescription)
            : base(name, decoration)
        {
            DoorDescription = doorDescription;
        }

        public string DoorDescription { get; private set; }

        public Location DoorLocation { get; set; }
    }
}



这是使它工作的WinForms。省略IDE生成的代码。

ExploreTheHouseForm.cs

using System;
using System.Windows.Forms;

namespace House
{
    public partial class ExploreTheHouseForm : Form
    {
        Location currentLocation;

        RoomWithDoor livingRoom;
        RoomWithDoor kitchen;
        Room diningRoom;
        OutsideWithDoor frontYard;
        OutsideWithDoor backYard;
        Outside garden;

        public ExploreTheHouseForm()
        {
            InitializeComponent();
            CreateObjects();
            MoveToLocation(livingRoom);
        }

        private void CreateObjects()
        {
            // Configure the locations
            livingRoom = new RoomWithDoor("living room", "an antique carpet", "an oak door with a brass knob");
            kitchen = new RoomWithDoor("kitchen", "stainless steel appliances", "a screen door");
            diningRoom = new Room("dining room", "a crystal chandelier");
            frontYard = new OutsideWithDoor("front yard", false, livingRoom.DoorDescription);
            backYard = new OutsideWithDoor("back yard", true, kitchen.DoorDescription);
            garden = new Outside("garden", false);

            // Configure the exits
            livingRoom.Exits = new Location[] { diningRoom };
            kitchen.Exits = new Location[] { diningRoom };
            diningRoom.Exits = new Location[] { livingRoom, kitchen };
            frontYard.Exits = new Location[] { backYard, garden };
            backYard.Exits = new Location[] { frontYard, garden };
            garden.Exits = new Location[] { frontYard, backYard };

            // Configure exterior doors
            livingRoom.DoorLocation = frontYard;
            frontYard.DoorLocation = livingRoom;
            kitchen.DoorLocation = backYard;
            backYard.DoorLocation = kitchen;
        }

        private void MoveToLocation(Location location)
        {
            currentLocation = location;
            ExitsComboBox.Items.Clear();
            foreach (Location exit in location.Exits)
            {
                ExitsComboBox.Items.Add(exit.Name);
            }
            ExitsComboBox.SelectedIndex = 0;
            DescriptionTextBox.Text = currentLocation.Description;
            ShowGoThroughExteriorDoorButton(currentLocation);
        }

        private void ShowGoThroughExteriorDoorButton(Location location)
        {
            if (location is IHasExteriorDoor)
            {
                GoThroughExteriorDoorButton.Visible = true;
                return;
            }
            GoThroughExteriorDoorButton.Visible = false;
        }

        private void GoHereButton_Click(object sender, EventArgs e)
        {
            MoveToLocation(currentLocation.Exits[ExitsComboBox.SelectedIndex]);
        }

        private void GoThroughExteriorDoorButton_Click(object sender, EventArgs e)
        {
            IHasExteriorDoor locationWithExteriorDoor = currentLocation as IHasExteriorDoor;
            MoveToLocation(locationWithExteriorDoor.DoorLocation);
        }
    }
}


评论

Winforms屏幕提供了通过外门的选项,但是外门后面没有显示任何可用位置吗?如果厨房有多个通往多个地方的外门怎么办?如果整个房子只是一个厨房怎么办?

如果您对如何编写基于文本的冒险的一般问题感兴趣,我鼓励您研究Inform7,这是一种用于编写此类冒险的极其聪明的语言。如果您对学习用于实现这种冒险的虚拟机感兴趣,那么几年前,我在我的博客上做了有关Z-machine的系列文章。

另外,在Z机代码上解决此问题的时间将少于一千字节。您将无法将上面的代码编译成占用大量代码的.NET程序集。 (这是另一种说法,“在过去,我们没有接口和抽象类。”但是,我们也更容易被烦恼所吞噬,所以最终一切都变得平坦了。) br />

#1 楼

RobH的评论很好地涵盖了语法和样式,因此我不再赘述。相反,我想对Svek和BittermanAndy的反馈意见表示赞同。

关注点分离

我认为Svek对CreateObjects方法的评论已备受关注,但我认为这还远远不够。首先,对这种方法的需求表明ExploreTheHouseForm类在很大程度上起到了作用。在当前的实现中,每个房间都是表单上的一个字段。这实际上使ExploreTheHouseForm成为了房屋本身。因此,将其更恰当地命名为ExplorableHouseForm

一般来说(随着您进入更复杂的项目,这一点变得越来越重要),我们希望将数据的表示形式与数据本身分开。 br />
表单是用户界面,它已经有责任向用户展示数据。它也不应该是数据。我宁愿将房子建在其他地方并传递给表单的构造函数:

    public ExploreTheHouseForm(Location initialRoom)
    {
        InitializeComponent();
        MoveToLocation(initialRoom);
    }


通过此简单更改,您可以使用Location除外。另外,如果您愿意,可以使用相同的表单来探索任意数量的不同房屋,而无需进一步修改。

抗扰性

大量的BittermanAndy的建议(截至在撰写本文时,他的帖子自从我开始以来至少更新了一次),目的是使您的ExploreTheHouseForm类不可变。由于按原样进行设计,因此位置之间需要相互参照,因此您遇到了鸡与蛋的情况,从而防止了不可变性,其中每个currentLocation都需要在其之前创建邻居。我没有找到解决这个问题的方法,但是,如果您有自己的位置来实现一个接口,并且编写表格来使用该接口而不是Location,则可以得到很多相同的实际不变性。

public interface ILocation
{
    public string Name { get; }
    public IList<ILocation> Exits {get;}
    public string Description { get;}
}


Location中,我们仅指定属性的Location部分。对于ILocation的使用者来说,即使实现类实现get,属性也实际上是只读的。我们还将ILocation声明为set的集合,而不是Exits的集合,这样访问的成员对消费者也是只读的。

您无需对ILocation本身进行太多更改:

public abstract class Location: ILocation
{
    ...
    //private field to back Exits property. 
    private IList<ILocation> _exits;

    public IList<ILocation> Exits {
       get
       {
           // AsReadOnly so that consumers are not allowed to modify contents.
           // there are other ways of accomplishing this that may be better overall, but ExploreTheHouseForm accesses Exits by index so we can only change it so much. 
           return _exits?.AsReadOnly();
       }
       set{ _exits = value;}
    }
}


更新Location也很简单,只需更改字段LocationExploreTheHouseFormcurrentLocationLocationExploreTheHouseForm参数到MoveToLocation:但是一旦构建完成,您将可以使用只读ShowGoThroughExteriorDoorButton

    ...
    private ILocation _currentLocation;
    ...
    public ExploreTheHouseForm(ILocation initialRoom)
    {
        InitializeComponent();
        MoveToLocation(initialRoom);
    }
    ...
    private void MoveToLocation(ILocation location)
    ... 
    private void ShowGoThroughExteriorDoorButton(ILocation location)


位置连通性

我同意其他评论和意见将位置连接的概念引入一个单独的类/类集将允许更好的设计。位置可以有任意数量的出口,这是出口的属性,而不是位置(如果出口是开放的拱门,门或只是抽象的分界线(室外位置到室外位置)),Commintern可以很好地完成在他们的评论中涵盖了这一点,因此我将不再赘述。

评论


\ $ \ begingroup \ $
很好的答案。分开房屋的创建,并仅通过导航所需的界面显示表单,可以很好地解决创建/不可变性问题。 (顺便说一下,我的编辑相当小)。
\ $ \ endgroup \ $
– BittermanAndy
19年1月18日在8:26

\ $ \ begingroup \ $
“还有其他方法可以总体上更好地完成此任务,但是ExploreTheHouseForm按索引访问Exits,因此我们只能进行很多更改。”至少我没有看到没有任何理由不直接将返回值更改为IReadOnlyList,这使意图更加清晰。诚然,对消费者而言,更好的解决方案是使用不可变集合包中的IImmutableList,它确实保证了列表内容不会更改。
\ $ \ endgroup \ $
–Voo
19年1月19日在13:25

#2 楼

我有一条一般规则:如果我看到带有循环的字符串连接,我认为这不是最好的方法。让我们看一下:

foreach (Location exit in Exits)
{
    description += $"— {exit.Name}";
    if (exit != Exits[Exits.Length - 1])
    {
        description += "\r\n";
    }
}


我经常看到这种模式。您需要的是带有Selectstring.Join

var exitList = string.Join(Environment.NewLine, Exits.Select(exit => $"— {exit.Name}"));


我使用了Environment.NewLine,因为我喜欢使用命名良好的常量。除了随机以外:\r是回车符,\n是换行符。该术语来自物理打印机。我更喜欢Environment.NewLine的另一个原因是,它意味着您不必了解和记住这一点。 。我认为这很重要,我真的应该第一次提到它。正如评论交流所显示的那样,很容易将平台和行尾混合在一起,我认为这说明了该常数的有用性。构造函数。您可以使用只读的自动属性而不是私有的set属性: br />

您在类声明中缺少访问修饰符。 MS准则指出应始终指定它们。我希望首先使用访问修饰符:Environment.NewLine。最主要的是保持一致。

评论


\ $ \ begingroup \ $
Environment.NewLine更适合Windows或Linux,因为它包含的值取决于操作系统。对于Windows,Environment.NewLine是“ \ r \ n”,而在Linux中,它是“ \ r”,但是通过使用Environment.NewLine,您不必担心这些细节。
\ $ \ endgroup \ $
–里克·戴文(Rick Davin)
19年1月17日在15:26

\ $ \ begingroup \ $
@RickDavin-是的,是的。我认为\ r是旧Mac,不是吗? Unix / linux使用\ n AFAIK。
\ $ \ endgroup \ $
– RobH
19年1月17日在15:34

\ $ \ begingroup \ $
我的错。是的,Linux是“ \ n”。见stackoverflow.com/questions/1015766/…
\ $ \ endgroup \ $
–里克·戴文(Rick Davin)
19年1月17日在16:59

#3 楼

您已经通过在构造函数中设置类型的属性并提供仅设置而不是仅获取的访问(例如,例如Location.Name)来使您的类型不可变。在合理的情况下,这是一个好习惯(因为,除其他外,这意味着您可以传递对象而不必担心其他事情会意外地改变它们的状态)。但是,我注意到Location.Exits是一个公共字段,这意味着可以在程序运行时将其替换为另一个数组-但是房子应该固定在结构上。作为更好的公共属性,最好将其作为构造函数中的另一个参数传递。

更巧妙地,不仅有人可以做类似currentLocation.Exits = ...的事情,上述建议可以防止这种情况;有人也可以做currentLocation.Exits[0] = ...,并且他们再次更改了应该修复的内容。 (当我说“某人”时,要理解这可能意味着“您,是错误的”,尤其是在更大,更复杂的程序中)。既然您已经提到过您已经学习了接口,那么Location.Exits的公共获取访问器应该是IEnumerable<Location>,它使事物可以通过exits数组枚举以查看它们是什么,但不能更改它们。 (如果您还没有使用过泛型,那么现在就不必为此担心太多。)

它最终会像这样: >
(很遗憾,我也不很乐意将出口作为数组传递。同样,有人可以在创建Location后更改该数组。我宁愿将出口作为IEnumerable<Location>传递并复制它们放入一个新的数组或Location私有的其他容器类型,但这引发了一些设计和对象所有权问题,这些问题在这里不太相关,因此不必担心)。更深入地了解位置/出口-这里有几件事,可能对这项工作来说是一个很大的变化,无需担心,但将来需要考虑。

首先,OutsideWithDoor继承自Outside并实现IHasExteriorDoor接口。这对您有用,但意味着从一个位置到另一个位置的出口是否有门的问题取决于位置的类型,而从逻辑上讲,这是它们之间连接的属性。 (它也仅限于每个位置只有一扇门,还有一些其他棘手的问题-希望避免不必要的继承,并且更喜欢使用组合而不是继承)。因此,我建议使用LocationConnection类型,其中Location是由LocationConnection而不是直接与其他Location连接的,而LocationConnection可以有门也可以没有门(布尔属性)。

第二,Location出口是双向出口,也就是说,如果您可以从一个位置转到另一个位置,则也可以始终返回。这是有道理的(如果您从厨房转到饭厅,无法回到厨房会很奇怪!),但是取决于初始化代码是否总是正确,这是常见的错误来源。 (如果建筑物是一栋有一百个房间的豪宅呢??)如果LocationConnection类型的实现得当,有人可以沿任一方向行驶,并且只需要编码一次,则此问题可能会消失。将来要记住的事情:每当您写AB的时候必须记住要写BA的时候,都会有人忘记这样做。

介绍这种新类型可能会带来比此代码审查真正合理的改变更大的变化,但它可以解决一些潜在的问题。


关于ShowGoThroughExteriorDoorButton的一些非常小的评论。首先,该方法的名称确定,但是听起来好像总是要显示该按钮。我称它为ShowOrHide...,尽管那只是我个人的喜好。而且,该方法中的if语句有点不雅致。我会简单地写:

abstract class Location
{
    public Location(string name, Location[] exits)
    {
        Name = name;
        _exits = exits;
    }

    public string Name { get; }

    private Location[] _exits;
    public IEnumerable<Location> Exits => _exits;

    // ...
}


...这消除了那些裸露的truefalse值,并且在方法中途还消除了丑陋的return。总的来说,尽管这种方法并不总是可行的,但通常还是希望方法在最后有一个退出点。尤其是当您开始使用异常时。对于private上的所有这些字段,尤其是ExploreTheHouseForm。另外,私有字段的通用约定是在它们前面加上下划线,例如private Location _currentLocation;,尽管并不是普遍遵循-我喜欢它,因为它有助于使参数或局部变量是什么,以及成员变量是什么变得明显。

评论


\ $ \ begingroup \ $
希望位置不可变并在构造函数中退出是一种巨大的愿望,但似乎与双向旅行不兼容。厨房有一个通往饭厅的出口,而饭厅有一个通往厨房的出口。为了遵循您的实施建议,必须先创建两者。提议的LocationConnection类似乎在防止不变性方面具有相同的问题。它必须引用它连接的位置,并且他们必须引用它。您将如何解决此冲突?
\ $ \ endgroup \ $
– Mindor先生
19年1月17日在21:45



\ $ \ begingroup \ $
公平点。一种可能的解决方案是创建不包含退出信息的位置,然后创建LocationConnections的列表/ LUT。然后,位置本身不会保留对其出口的引用-每次它想知道时,都必须检查LocationConnection列表。这比仅允许Location类可变还是好还是坏?这时将成为判断调用,并取决于将如何使用这些类。 Mutable很棒,“只要合理就可以这样做”……并非总是如此。
\ $ \ endgroup \ $
– BittermanAndy
19年1月17日在22:34



\ $ \ begingroup \ $
非常有趣的答案,谢谢!以后我会考虑这一点!
\ $ \ endgroup \ $
– ran
19年1月18日,下午2:36

\ $ \ begingroup \ $
Comintern的(好的)答案提供了一些退出/连接类型的示例。
\ $ \ endgroup \ $
– BittermanAndy
19年1月18日在8:21

\ $ \ begingroup \ $
@MrMindor解决此问题的通常方法(除了重新设计以避免循环引用)是在创建过程中使用生成器模式,该模式是可变的,并从该模式生成不可变的类。
\ $ \ endgroup \ $
–Voo
19年1月19日在13:08

#4 楼

其他评论涵盖了我要提出的许多要点,但还有其他一些要点。


抽象类中的构造函数应该是protected,而不是public
<不能创建抽象类的新实例,所以无论如何它都是有目的和用途的。使访问修饰符与行为匹配。


我不确定我是否喜欢某些命名。例如,protected仅给出丝毫提示。我可能会更喜欢CreateObjects()。几个成员名称在功能上也有些含糊-例如,相对于哪个房间GenerateMap()? (不确定该练习禁止了多少),我还认为让出口成为主要动机对象更为自然。对我而言,考虑“使用门”比考虑“离开房间”更自然。对我来说,房间是静态的东西-它有出口,但是您实际上并没有离开房间来使用房间。我会考虑从位置之间的联系而不是位置本身的角度来构建地图。像这样的东西:

public Location(string name) => Name = name;    



public interface ILocation
{
    string Name { get; }
    string Description { get; }
}



public interface IExit
{
    // TODO: my naming sucks too.
    ILocation LocationOne { get; }
    ILocation LocationTwo { get; }
    // Takes the room you're exiting as a parameter, returns where you end up.
    ILocation Traverse(ILocation from);
}


您可以从更自然的方向(IMO​​和无双关)着眼于位置之间的空间关系。当您需要关闭或锁定门时,您可能会发现它更容易扩展:

public abstract class Location : ILocation
{
    private readonly IReadOnlyList<IExit> _exits;

    protected Location(string name, string description, IEnumerable<IExit> exits)
    {
        _exits = exits.ToList();
    }

    public IEnumerable<IExit> Exits => _exits;

    // ...other properties...
}


评论


\ $ \ begingroup \ $
为什么同时具有ILocation接口和Location抽象类?由于您具有Location抽象类,因此我希望将Name和Description移至Location作为抽象属性。
\ $ \ endgroup \ $
– inwenis
19年2月10日在20:18

\ $ \ begingroup \ $
@inwenis属于// //其他属性...注释。示例位置旨在突出显示出口。
\ $ \ endgroup \ $
–共产国际
19年2月10日在20:34

\ $ \ begingroup \ $
我读过其他文章,似乎ILocation可用于将位置作为不可变对象传递。很好,但是没有任何建议,我建议将它们合并。好吧,也许现在,在我看到它可以用作什么之后,我会怀疑。
\ $ \ endgroup \ $
– inwenis
19年2月10日在21:38

#5 楼

在此代码块中:

...
OutsideWithDoor backYard;
Outside garden;

public ExploreTheHouseForm()
{
    InitializeComponent();
    CreateObjects(); // <--- bleh
    MoveToLocation(livingRoom);
}


这是对CreateObject()方法的调用,这是我不希望在代码中看到的(这可能是个人风格问题),但是如果您要构造一个对象,那么与对象构造相关的所有代码都应保留在构造函数中...

我希望它最终看起来像

...
private readonly OutsideWithDoor _backYard;  // now it can be readonly
private readonly Outside _garden;

public ExploreTheHouseForm()
{
    InitializeComponent();

    ...
    _backYard = new OutsideWithDoor("back yard", true, kitchen.DoorDescription);
    _garden = new Outside("garden", false);

    MoveToLocation(livingRoom);
}


评论


\ $ \ begingroup \ $
“与对象的构造有关的所有代码都应保留在构造函数中”,我对此表示不同意。如果是单线的?内联进行-没有必要创建辅助方法。但是对于更复杂的初始化,将逻辑上独立的部分分成自己的方法,可以使方法小巧,简单且具有自说明性。否则,我们最终得到一个巨大的构造器方法,该方法需要使用注释来分隔不同的部分。现在,您确实确实想让事物保持只读状态,但是通常的解决方案是简单地返回创建的对象并将其分配给构造函数。
\ $ \ endgroup \ $
–Voo
19年1月19日在13:20