我正在制作角色扮演游戏,只是为了好玩,并了解有关SOLID原则的更多信息。我关注的第一件事就是SRP。我有一个“角色”类,代表游戏中的角色。它有Name,Health,Mana,AbilityScores等等。
现在,通常我也会在我的Character类中放置方法,所以它看起来像这样......
public class Character
{
public string Name { get; set; }
public int Health { get; set; }
public int Mana { get; set; }
public Dictionary<AbilityScoreEnum, int>AbilityScores { get; set; }
// base attack bonus depends on character level, attribute bonuses, etc
public static void GetBaseAttackBonus();
public static int RollDamage();
public static TakeDamage(int amount);
}
但是由于SRP,我决定将所有方法都移到一个单独的类中。我将该类命名为“CharacterActions”,现在方法签名看起来像这样......
public class CharacterActions
{
public static void GetBaseAttackBonus(Character character);
public static int RollDamage(Character character);
public static TakeDamage(Character character, int amount);
}
请注意,我现在必须在我的所有CharacterActions方法中包含我正在使用的Character对象。这是利用SRP的正确方法吗?它似乎完全违背了封装的OOP概念。
或者我在这里做错了什么?
我喜欢这件事的一件事是我的Character类非常清楚它的作用,它只是代表一个Character对象。
答案 0 :(得分:22)
更新 - 我已经重做了我的回答,因为在半夜睡眠后,我真的不觉得我之前的回答非常好。
要查看SRP的实例,让我们考虑一个非常简单的字符:
public abstract class Character
{
public virtual void Attack(Character target)
{
int damage = Random.Next(1, 20);
target.TakeDamage(damage);
}
public virtual void TakeDamage(int damage)
{
HP -= damage;
if (HP <= 0)
Die();
}
protected virtual void Die()
{
// Doesn't matter what this method does right now
}
public int HP { get; private set; }
public int MP { get; private set; }
protected Random Random { get; private set; }
}
好的,所以这将是一个非常无聊的RPG。但是这个类有意义。这里的所有内容都与Character
直接相关。每种方法都是由Character
执行或执行的操作。嘿,游戏很简单!
让我们专注于Attack
部分,并尝试让这一点变得有趣:
public abstract class Character
{
public const int BaseHitChance = 30;
public virtual void Attack(Character target)
{
int chanceToHit = Dexterity + BaseHitChance;
int hitTest = Random.Next(100);
if (hitTest < chanceToHit)
{
int damage = Strength * 2 + Weapon.DamageRating;
target.TakeDamage(damage);
}
}
public int Strength { get; private set; }
public int Dexterity { get; private set; }
public Weapon Weapon { get; set; }
}
现在我们到了某个地方。角色有时会错过,并且伤害/命中随着等级上升(假设STR也增加)。很高兴,但这仍然相当沉闷,因为它没有考虑目标的任何事情。让我们看看我们是否可以解决这个问题:
public void Attack(Character target)
{
int chanceToHit = CalculateHitChance(target);
int hitTest = Random.Next(100);
if (hitTest < chanceToHit)
{
int damage = CalculateDamage(target);
target.TakeDamage(damage);
}
}
protected int CalculateHitChance(Character target)
{
return Dexterity + BaseHitChance - target.Evade;
}
protected int CalculateDamage(Character target)
{
return Strength * 2 + Weapon.DamageRating - target.Armor.ArmorRating -
(target.Toughness / 2);
}
此时,问题应该已经在你的脑海中形成:为什么Character
负责计算自己对目标的伤害?为什么它甚至具备这种能力?关于这个类正在做什么,有一些无形的怪异的,但在这一点上,它仍然有点含糊不清。仅仅从Character
类中移出几行代码真的值得重构吗?可能不是。
但让我们来看看当我们开始添加更多功能时会发生什么 - 比如20世纪90年代典型的RPG:
protected int CalculateDamage(Character target)
{
int baseDamage = Strength * 2 + Weapon.DamageRating;
int armorReduction = target.Armor.ArmorRating;
int physicalDamage = baseDamage - Math.Min(armorReduction, baseDamage);
int pierceDamage = (int)(Weapon.PierceDamage / target.Armor.PierceResistance);
int elementDamage = (int)(Weapon.ElementDamage /
target.Armor.ElementResistance[Weapon.Element]);
int netDamage = physicalDamage + pierceDamage + elementDamage;
if (HP < (MaxHP * 0.1))
netDamage *= DesperationMultiplier;
if (Status.Berserk)
netDamage *= BerserkMultiplier;
if (Status.Weakened)
netDamage *= WeakenedMultiplier;
int randomDamage = Random.Next(netDamage / 2);
return netDamage + randomDamage;
}
这一切都很好,但是在Character
课程中进行所有这些数字运算并不是有点荒谬吗?这是一个相当短的方法;在一个真正的角色扮演游戏中,这种方法可以延伸到数百行中,具有豁免检定和所有其他方式的神经。想象一下,你带来了一个新的程序员,他们说:我得到了一个双击武器的请求,无论通常是什么样的伤害都应该加倍;我需要在哪里进行更改?你告诉他,检查Character
课程。 嗯??
更糟糕的是,也许游戏会增加一些新的皱纹,哦,我不知道,背刺奖金,或其他类型的环境奖金。那你到底应该怎样在Character
班上解决这个问题呢?你可能最终会呼唤一些单身人士,比如:
protected int CalculateDamage(Character target)
{
// ...
int backstabBonus = Environment.Current.Battle.IsFlanking(this, target);
// ...
}
呸。这太糟糕了。测试和调试这将是一场噩梦。那么我们该怎么办?把它从Character
课程中拿出来。 Character
类应仅知道如何执行Character
逻辑上知道如何操作的事情,并且计算针对目标的确切损害确实不是其中之一。我们将为它上课:
public class DamageCalculator
{
public DamageCalculator()
{
this.Battle = new DefaultBattle();
// Better: use an IoC container to figure this out.
}
public DamageCalculator(Battle battle)
{
this.Battle = battle;
}
public int GetDamage(Character source, Character target)
{
// ...
}
protected Battle Battle { get; private set; }
}
好多了。这个课完全是一件事。它完成它在锡上的说法。我们已经摆脱了单例依赖,所以这个类实际上现在可以测试了,而且感觉更正确,不是吗?现在我们的Character
可以专注于Character
行动:
public abstract class Character
{
public virtual void Attack(Character target)
{
HitTest ht = new HitTest();
if (ht.CanHit(this, target))
{
DamageCalculator dc = new DamageCalculator();
int damage = dc.GetDamage(this, target);
target.TakeDamage(damage);
}
}
}
即使现在,有一个Character
直接调用另一个Character
的{{1}}方法也有点疑问,实际上你可能只想让角色“提交”它攻击某种战斗引擎,但我认为这部分最好留给读者练习。
现在,希望你理解为什么:
TakeDamage
......基本没用。怎么了?
public class CharacterActions
{
public static void GetBaseAttackBonus(Character character);
public static int RollDamage(Character character);
public static TakeDamage(Character character, int amount);
}
本身不能做的任何事情; Character
而不是其他任何内容; Character
类的部分内容。 Character
类打破了CharacterActions
封装,并且几乎没有添加任何内容。另一方面,Character
类提供了新的封装,并通过消除所有不必要的依赖关系和不相关的功能来帮助恢复原始DamageCalculator
类的内聚。如果我们想要改变计算损害的方式,那么显而易见在哪里看。
我希望现在能更好地解释这个原则。
答案 1 :(得分:7)
SRP并不意味着某个类不应该有方法。您所做的是创建数据结构而不是多态对象那个。这样做有好处,但在这种情况下可能没有意图或需要。
您经常可以判断对象是否违反SRP的一种方法是查看对象中方法使用的实例变量。如果有一组方法使用某些实例变量而不是其他实例变量,那通常表明您的对象可以根据实例变量组进行拆分。
此外,您可能不希望您的方法是静态的。您可能希望利用多态性 - 根据调用该方法的实例的类型,在您的方法中执行不同的操作。
例如,如果您有ElfCharacter
和WizardCharacter
,您的方法是否需要更改?如果你的方法永远不会改变并且完全是自包含的,那么静态就好了......但即便如此,它也会使测试变得更加困难。
答案 2 :(得分:2)
我认为这取决于你的角色行为是否会改变。例如,如果您希望更改可用的操作(基于RPG中发生的其他事情),您可以选择以下内容:
public interface ICharacter
{
//...
IEnumerable<IAction> Actions { get; }
}
public interface IAction
{
ICharacter Character { get; }
void Execute();
}
public class BaseAttackBonus : IAction
{
public BaseAttackBonus(ICharacter character)
{
Character = character;
}
public ICharacter Character { get; private set; }
public void Execute()
{
// Get base attack bonus for character...
}
}
这允许你的角色拥有你想要的任意数量的动作(这意味着你可以在不改变角色类的情况下添加/删除动作),并且每个动作只负责自己(但是知道角色)以及有更多动作的动作复杂的需求,从IAction继承以添加属性等。您可能想要一个不同的Execute返回值,并且您可能需要一个操作队列,但是您可以获得漂移。
注意使用ICharacter而不是Character,因为字符可能具有不同的属性和行为(术士,向导等),但它们可能都有动作。
通过分离动作,它还使测试变得更加容易,因为您现在可以测试每个动作而无需连接完整的角色,而使用ICharacter,您可以更轻松地创建自己的(模拟)角色。
答案 3 :(得分:2)
我不知道我真的会在第一时间称这种类型的SRP。 “处理foo的一切”通常都表明你在SRP之后不(这没关系,它不适合所有类型的设计)。
查看SRP边界的一个好方法是“有什么我可以改变的类,这将使大多数类保持原样吗?”如果是这样,将它们分开。或者,换句话说,如果你触摸一个类中的一个方法,你可能需要触摸所有这些方法。 SRP的一个优点是它可以最大限度地减少您在进行更改时触摸的范围 - 如果另一个文件未被触及,您知道您没有添加错误!
特别是角色类在RPG中成为神级的危险程度很高。避免这种情况的一种可能方法是从不同的方式处理 - 从您的UI开始,并在每一步,从您当前正在编写的类的角度断言您希望存在的接口 已经存在。另外,请查看控制反转原理以及使用IoC时设计如何变化(不一定是IoC容器)。
答案 4 :(得分:0)
我在设计类时所采用的方法以及OO所基于的方法是对象模拟现实世界的对象。
让我们来看看角色...... 设计一个Character类非常有趣。 这可能是一份合同 ICharacter 这意味着任何想要成为角色的人都应该能够执行Walk(),Talk(),Attack(),拥有一些属性,例如Health,Mana。
然后你可以拥有一个向导,一个具有特殊属性的向导,以及他攻击的方式不同,比如说战士。
我倾向于不会过于强迫设计原则,而是考虑建模现实世界的对象。