强类型字符串

27

背景

我有一个原型类TypedString<T>,旨在对某个特定类别的字符串进行"强类型"(含糊的意义)处理。它使用了C#中类似于奇异递归模板模式(CRTP)的技术。

class TypedString<T>

public abstract class TypedString<T>
    : IComparable<T>
    , IEquatable<T>
    where T : TypedString<T>
{
    public string Value { get; private set; }

    protected virtual StringComparison ComparisonType
    {
        get { return StringComparison.Ordinal; }
    }

    protected TypedString(string value)
    {
        if (value == null)
            throw new ArgumentNullException("value");
        this.Value = Parse(value);
    }

    //May throw FormatException
    protected virtual string Parse(string value)
    {
        return value;
    }

    public int CompareTo(T other)
    {
        return string.Compare(this.Value, other.Value, ComparisonType);
    }

    public bool Equals(T other)
    {
        return string.Equals(this.Value, other.Value, ComparisonType);
    }

    public override bool Equals(object obj)
    {
        return obj is T && Equals(obj as T);
    }

    public override int GetHashCode()
    {
        return Value.GetHashCode();
    }

    public override string ToString()
    {
        return Value;
    }
}

TypedString<T>类现在可用于消除在项目中定义一堆不同"字符串类型"时的代码重复。一个简单的示例是在定义Username类时使用这个类:

class Username(示例)

public class Username : TypedString<Username>
{
    public Username(string value)
        : base(value)
    {
    }

    protected override string Parse(string value)
    {
        if (!value.Any())
            throw new FormatException("Username must contain at least one character.");
        if (!value.All(char.IsLetterOrDigit))
            throw new FormatException("Username may only contain letters and digits.");
        return value;
    }
}

现在我可以在整个项目中使用Username类,无需检查用户名是否格式正确 - 如果我有一个类型为Username的表达式或变量,它是保证正确的(或为空)。

场景1

string GetUserRootDirectory(Username user)
{
    if (user == null)
        throw new ArgumentNullException("user");
    return Path.Combine(UsersDirectory, user.ToString());
}

我不必担心用户字符串的格式 - 我已经知道由于它的类型而是正确的。

情景2

IEnumerable<Username> GetFriends(Username user)
{
    //...
}

在这里,调用方仅基于类型就知道返回的内容。一个 IEnumerable<string> 需要查看方法或文档的详细信息。更糟糕的是,如果有人更改了 GetFriends 的实现,例如引入错误并生成无效的用户名字符串,那么该错误可能会悄悄地传播到方法的调用者,并造成各种混乱。这种很好的类型化版本可以防止这种情况发生。

场景三

System.Uri 是 .NET 中的一个类的示例,它几乎只是包装了一个具有大量格式约束和有用部分的帮助属性/方法的字符串。因此,这是这种方法不完全疯狂的证据之一。

问题

我想象中已经有人做过这种事情了。我已经看到了这种方法的好处,不需要再说服自己了。

我可能忽略了什么缺点?
这种方法是否会在以后反噬我?


2
对我来说,这只是以不同名称呈现的面向对象编程。如果UserName被正确编写,TypedString<UserName>UserName类在功能上可以是相同的--TypedString只是为您提供了一个强制继承模式。 - Hogan
1
@TimothyShields:就一般情况而言,我同意关于米的例子。但是,如果你正在编写一个单位转换程序或者科学软件(月球着陆器模块?-:)),那么像米这样的类将非常有用和必要。它可以防止某人在需要米的地方使用英里实例..更好的是,通过一些运算符重载,它可以自动将英里转换为米。重点是,根据需要使用(或滥用)数据类型。 - Sam Axe
1
"我想这种事情以前可能已经做过了。我已经看到了这种方法的好处,不需要再说服自己了。" - 我并不认为在不同的“类”中散布字符串验证代码有什么好处,这些“类”除了验证字符串之外没有其他行为。如果让我来维护这些无意义的代码,我会很生气,因为这些字符串验证应该在序列化/反序列化边界处处理。 - Ritch Melton
5
我认为更合适的术语是“字符串类型”。 - Anthony Pegram
2
@TimothyShields - 我没有给你的问题贴上反对票。我并不认为这是一个糟糕的问题,只是我认为这种方法并不实用。个人见解可能有所不同。 - Ritch Melton
显示剩余7条评论
4个回答

7

总体思路

我并不完全反对这种方法(使用CRTP是很有用的,你能够知道和使用它是值得表扬的)。这种方法允许将元数据包装在单个值周围,这可能是非常好的。它也是可扩展的;您可以向类型添加其他数据而不会破坏接口。

我不喜欢您当前的实现似乎严重依赖于基于异常的流程。这可能对某些事情或在真正特殊的情况下是完全合适的。然而,如果用户试图选择有效的用户名,则在此过程中可能会抛出数十个异常。

当然,您可以在接口中添加无异常验证。您还必须问自己您希望验证规则存在的位置(这始终是一个挑战,特别是在分布式应用程序中)。

WCF

说到“分布”,请考虑将此类类型作为WCF数据契约的一部分实现的影响。忽略数据契约通常应公开简单DTO的事实,您还面临代理类的问题,这些类将维护您的类型属性,但不维护其实现。

当然,您可以通过在客户端和服务器上放置父程序集来减轻这种情况。在某些情况下,这是完全合适的。在其他情况下,则不太合适。假设您的字符串之一的验证需要调用数据库。这很可能不适合在客户端/服务器位置都存在。

“场景1”

听起来您正在寻求一致的格式。这是一个值得追求的目标,并且对于诸如URI和用户名之类的东西非常有效。对于更复杂的字符串,这可能是一个挑战。我曾经参与过的产品甚至连“简单”的字符串也可以根据上下文以许多不同的方式进行格式化。在这种情况下,专用(并且可能是可重用的)格式化程序可能更为合适。

再次强调,这非常具体化。

“场景2”

更糟糕的是,如果有人更改了GetFriends的实现,使其引入错误并生成无效的用户名字符串,则该错误可能会悄悄地传播到方法的调用者并造成各种麻烦。

IEnumerable<Username> GetFriends(Username user) { }

我可以理解此论点,有几个事情需要考虑:
  • 更好的方法名:GetUserNamesOfFriends()
  • 单元/集成测试
  • 假设这些用户名在创建/修改时进行了验证。如果这是您自己的API,为什么不信任它给您的内容?

顺便提一下:当涉及到人员/用户时,不变的ID可能更有用(人们喜欢更改用户名)。

"场景3"

System.Uri就是.NET中的一个类的示例,它几乎仅包装了一个有很多格式限制和有用部分的帮助属性/方法的字符串。因此,这证明了这种方法并不完全疯狂。

毫无争议,在BCL中有许多这样的例子。

最后思考

  • 将值封装到更复杂的类型中以便使用更丰富的元数据进行描述/操作没有问题。
  • 将验证集中在单个位置是一件好事,但请确保您选择了正确的位置。
  • 当逻辑存在于传递的类型内部时,在跨序列化边界时可能会遇到挑战。
  • 如果您主要关注信任输入,则可以使用简单的包装类,让被调用者知道它正在接收已验证的数据。这个验证发生在哪里/如何发生并不重要。

ASP.Net MVC对字符串使用了类似的范例。如果值是IMvcHtmlString,则将视为受信任的内容并且不会再次进行编码。否则,将进行编码。


关于 GetFriends 的名称:它完全是为了我的问题而发明的——签名比名称更重要——我的应用程序与 "朋友" 没有任何关系。哈哈 :) —— 关于 "场景 2" 和信任自己的 API: 如果您在所有地方都使用 string,只要有一个人在一个位置忘记了如何反序列化/解析一个 "特殊" 字符串以正确格式化/转换/规范化该字符串,错误就会悄悄地渗透到整个系统中。然而,Username 示例类型通过其类型的性质保证不会发生这种情况。 - Timothy Shields
非常好的回答,它是荣誉勾选的候选人。 ;) - Timothy Shields
谢谢。只要您保持一致并与使用您代码的任何其他人分享知识,我肯定不反对这种范例。我经常将值封装在各种包装器中。 - Tim M.

3
你定义了一个基类,用于表示可以从字符串解析出来的对象表达式。除此之外,将基类中的所有成员声明为虚函数,看起来很好。稍后你可以考虑管理序列化、大小写等问题。
这种对象表达式在基础类库中被使用,例如System.Uri
Uri uri = new Uri("ftp://myUrl/%2E%2E/%2E%2E");
Console.WriteLine(uri.AbsoluteUri);
Console.WriteLine(uri.PathAndQuery);

使用这个基类可以简单实现对部件的易用访问(例如System.Uri),具有强类型成员、验证等功能。我唯一看到的缺点是C#不允许多重继承,但你也许并不需要继承其他任何类。

1
你提供的 System.Uri 类的示例非常鼓舞人心,如果它不是已经在 .NET 中存在,那么它实际上将成为应用这个 TypedString<T> 的一个很好的例子。另外,关于将基类的所有方法都设置为虚方法的观点也很好。 - Timothy Shields
1
IHtmlString是另一个很好的BCL示例,因为它只是一个已知为HTML编码的字符串。 - default.kramer

3

以下是我想到的两个缺点:

1)维护开发人员可能会感到意外。他们也可能决定使用CLR类型,然后你的代码库就分成了在某些地方使用string username和在其他地方使用Username username的代码。

2)你的代码可能会变得混乱,充满了对new Username(str)username.Value的调用。现在可能看起来不是很重要,但当你第20次输入username.StartsWith("a")并等待智能感知告诉你有问题时,然后再思考并将其更正为username.Value.StartsWith("a")时,你可能会感到烦恼。

我相信你真正想要的是Ada称之为“约束子类型”,但我自己从未使用过Ada。在C#中,最好的选择是一个包装器,这不太方便。


0
我建议采用另一种设计。
定义一个简单的接口来描述解析规则(字符串语法):
internal interface IParseRule
{
    bool Parse(string input, out string errorMessage);
}

为用户名定义解析规则(以及您拥有的其他规则):

internal class UserName : IParseRule
{
    public bool Parse(string input, out string errorMessage)
    {
        // TODO: Do your checks here
        if (string.IsNullOrWhiteSpace(input))
        {
            errorMessage = "User name cannot be empty or consist of white space only.";
            return false;
        }
        else
        {
            errorMessage = null;
            return true;
        }
    }
}

然后添加一些利用该接口的扩展方法:

internal static class ParseRule
{
    public static bool IsValid<TRule>(this string input, bool throwError = false) where TRule : IParseRule, new()
    {
        string errorMessage;
        IParseRule rule = new TRule();

        if (rule.Parse(input, out errorMessage))
        {
            return true;
        }
        else if (throwError)
        {
            throw new FormatException(errorMessage);
        }
        else
        {
            return false;
        }
    }

    public static void CheckArg<TRule>(this string input, string paramName) where TRule : IParseRule, new()
    {
        string errorMessage;
        IParseRule rule = new TRule();

        if (!rule.Parse(input, out errorMessage))
        {
            throw new ArgumentException(errorMessage, paramName);
        }
    }

    [Conditional("DEBUG")]
    public static void DebugAssert<TRule>(this string input) where TRule : IParseRule, new()
    {
        string errorMessage;
        IParseRule rule = new TRule();
        Debug.Assert(rule.Parse(input, out errorMessage), "Malformed input: " + errorMessage);
    }
}

现在您可以编写干净的代码来验证字符串的语法:

    public void PublicApiMethod(string name)
    {
        name.CheckArg<UserName>("name");

        // TODO: Do stuff...
    }

    internal void InternalMethod(string name)
    {
        name.DebugAssert<UserName>();

        // TODO: Do stuff...
    }

    internal bool ValidateInput(string name, string email)
    {
        return name.IsValid<UserName>() && email.IsValid<Email>();
    }

这与我问题中的“场景2”有何关联?看起来我所提到的“保证”在这里丢失了?- 这种方法还要求每个以任何这些“强类型”字符串作为输入的方法都要记得为每个字符串都有一个CheckArg/DebugAssert - Timothy Shields
这个想法是在API输入参数时进行检查。在内部,您使用调试条件代码来断言规则是否被满足。通过这种方式,您可以最小化解析的需求,获得干净的代码,并且由于调试断言失败而及早发现错误。 - Mårten Wikström

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