一个真实的排序 bug

在局数据管理系统中,Excel 导入的「三层目的IP地址」列是一个典型的”多态”字段:同一个单元格里可能是单个 IP(10.0.0.9)、CIDR 网段(10.0.0.0/24),也可能是范围写法(10.0.0.1-10.0.0.254)。导入后需要按 IP 排序、去重、检测重合记录——这时问题就来了:

var ips = new List<string> { "10.0.0.10", "10.0.0.9", "192.168.1.1" };
ips.Sort();
// 结果:10.0.0.10、10.0.0.9、192.168.1.1
// 字符串字典序里 "10.0.0.10" 反而排在 "10.0.0.9" 前面!

字符串按字典序逐字符比较,'1' < '9',所以 10.0.0.10 排在 10.0.0.9 之前。更麻烦的是,.NET 的 IPAddress 类型本身没有实现 IComparable<IPAddress>,直接扔进 Sort() 会抛 InvalidOperationException。

本文以 SwitchData 项目中的 IPInterval(IP 区间)和 PortInterval(端口区间)为例,拆解一套完整的解法:字节序比较 + 三个泛型接口定义比较语义。

第一步:让 IPAddress 可比较——按字节逐段比较

IP 地址的本质是一个定长字节数组:IPv4 是 4 字节,IPv6 是 16 字节。要比较大小,只需保证两边协议族一致,然后逐字节比较即可。这也是字典序陷阱的根源——排序必须发生在字节层面,而不是字符层面。

public static int Compare(this IPAddress addressA, IPAddress addressB)
{
    // IPv4 与 IPv6 是不同的协议族,不可直接比较
    if (addressA.AddressFamily != addressB.AddressFamily)
    {
        throw new Exception($"addressA[{addressA}]类型与addressB[{addressB}]类型不一致");
    }

    byte[] bytesA = addressA.GetAddressBytes();
    byte[] bytesB = addressB.GetAddressBytes();

    return NetHelper.Compare(bytesA, bytesB);
}

NetHelper.Compare 就是一个朴素的逐字节循环:

public static int Compare(byte[] bytesA, byte[] bytesB)
{
    if (bytesA.Length != bytesB.Length)
    {
        throw new ArgumentException("无法比较长度不同的字节数组");
    }

    for (int i = 0; i < bytesA.Length; i++)
    {
        if (bytesA[i] > bytesB[i]) return 1;
        else if (bytesA[i] < bytesB[i]) return -1;
    }

    return 0;
}

这样 10.0.0.9 与 10.0.0.10 的比较就变成了 0A 00 00 09 与 0A 00 00 0A 的比较,0x09 < 0x0A,结果正确。

有了单地址比较,还要解决 CIDR 网段的两个边界地址。网段 10.0.0.0/24 的起始地址就是网络地址本身,结束地址(广播地址)需要保留掩码位、填充主机位:

// 计算网段最后一个地址(广播地址)
public static IPAddress Last(this IPAddress address, int nMask)
{
    byte[] bytes = address.GetAddressBytes();
    int len = bytes.Length;
    byte[] resultBytes = new byte[len];
    for (int i = 0; i < len; i++)
    {
        if (nMask >= 8)          // 整字节落在掩码内:原样保留
        {
            resultBytes[i] = bytes[i];
        }
        else if (nMask > 0)      // 半字节:低位全部置 1
        {
            resultBytes[i] = (byte)((255 >> nMask) | bytes[i]);
        }
        else                     // 掩码已耗尽:主机位全 1
        {
            resultBytes[i] = 255;
        }
        nMask = nMask - 8;
    }
    return new IPAddress(resultBytes);
}

First(address, nMask) 与之对称:把 (255 >> nMask) 换成 (255 << (8 - nMask)),主机位全部置 0。

第二步:把「地址 + 掩码」建模成区间对象

有了边界地址的计算能力,就可以把三种写法统一成一个区间模型:

public class IPInterval : IComparable<IPInterval>, IComparer<IPInterval>, IEquatable<IPInterval>
{
    public IPAddress Start { get; protected set; }
    public IPAddress End { get; protected set; }
    public int Mask { get; protected set; }

    // 写法一:显式范围,end 为空表示单地址
    public IPInterval(IPAddress start, IPAddress end = null, char intervalSeparator = '-')
    {
        ArgumentNullException.ThrowIfNull(start);
        Start = start;

        if (end == null || end.Equals(start))
        {
            End = null;                          // 单地址:End 置空
        }
        else if (end.Compare(start) < 0)
        {
            throw new ArgumentException($"end[{end}]必须大于或等于start[{start}]");
        }
        else
        {
            End = end;
        }
    }

    // 写法二:CIDR 掩码,自动推导广播地址
    public IPInterval(IPAddress start, int nMask, char intervalSeparator = '-')
    {
        ArgumentNullException.ThrowIfNull(start);
        Start = start;

        int nMaxMask = Start.GetAddressBits();   // IPv4=32,IPv6=128
        if (nMask <= 0 || nMask > nMaxMask)
        {
            throw new ArgumentException($"nMask[{nMask}]必须在(0,{nMaxMask}]区间内");
        }
        Mask = nMask;

        if (Mask == nMaxMask)
        {
            End = null;                          // /32 单地址
        }
        else
        {
            End = Start.Last(Mask);              // 广播地址
        }
    }
}

注意两个设计细节:

  1. End == null 是一个语义信号,表示”单地址”或”/32 单主机网段”,而不是没有结束地址;
  2. 构造函数即校验:end < start 直接抛异常,保证区间对象创建出来就是合法的——这符合”让非法状态不可表示”的设计原则。

第三步:三个泛型接口定义完整的比较语义

这是本文的核心知识点。一个”可比较、可排序、可判等”的区间类型,需要同时实现三个接口:

flowchart TD
    A["IComparable&lt;T&gt;<br/>CompareTo: 默认排序"] --> D["List.Sort()<br/>OrderBy / SortedSet"]
    B["IComparer&lt;T&gt;<br/>Compare: 双键排序"] --> D
    B --> E["显式指定比较器<br/>Array.Sort(arr, comparer)"]
    C["IEquatable&lt;T&gt;<br/>Equals + GetHashCode"] --> F["Distinct()<br/>Dictionary / HashSet"]

① IComparer<T>:双键排序——先比起始地址,起始相同再比结束地址,单地址(End == null)排在区间的最前面:

public int Compare(IPInterval source, IPInterval target)
{
    if (source.Start.AddressFamily != target.Start.AddressFamily)
    {
        // IPv4 统一排在 IPv6 前面
        if (source.Start.AddressFamily == AddressFamily.InterNetwork)
        {
            return -1;
        }
        else
        {
            return 1;
        }
    }
    else
    {
        int result = source.Start.Compare(target.Start);   // 第一键:起始地址
        if (result == 0)
        {
            if (source.End == null && target.End == null) return 0;
            else if (source.End == null) return -1;        // 单地址排最前
            else if (target.End == null) return 1;
            else return source.End.Compare(target.End);    // 第二键:结束地址
        }
        else
        {
            return result;
        }
    }
}

这里有个容易忽略的点:IPv4 和 IPv6 无法按字节比较,工程上通常约定协议族本身作为最高位排序键(IPv4 在前),否则混排场景会直接抛异常。

② IComparable<T>:委托给 IComparer 实现,避免两处逻辑不一致:

public int CompareTo(IPInterval target)
{
    return Compare(this, target);
}

③ IEquatable<T>:让相等语义与排序语义严格一致——CompareTo 返回 0 就视为相等:

public bool Equals(IPInterval obj)
{
    if (ReferenceEquals(null, obj)) return false;
    if (ReferenceEquals(this, obj)) return true;
    return CompareTo(obj) == 0;
}

public override bool Equals(object obj)
{
    if (ReferenceEquals(null, obj)) return false;
    if (ReferenceEquals(this, obj)) return true;
    if (obj.GetType() != GetType()) return false;   // 防止与子类混比
    return CompareTo((IPInterval)obj) == 0;
}

public override int GetHashCode()
{
    return string.Format("{0}{1}{2}", Start, IntervalSeparator, End).GetHashCode();
}

为什么必须重写 GetHashCode? 因为 .NET 的铁律是:Equals 为 true 的两个对象,GetHashCode 必须相同。区间对象重写了 Equals,如果不同步重写哈希,Distinct()、Dictionary、HashSet 全都会失效——两个”相等”的区间会被当成不同元素。这里用 Start + 分隔符 + End 拼接字符串取哈希,简单可靠(.NET 内置 string.GetHashCode 本身就是稳定的一致性哈希)。

第四步:同构设计复用到端口段

端口区间 PortInterval 与 IPInterval 结构完全同构——把 IPAddress 换成 int,把 -1 作为”单端口”的哨兵值:

public class PortInterval : IComparable<PortInterval>, IComparer<PortInterval>, IEquatable<PortInterval>
{
    public int Start { get; protected set; }
    public int End { get; protected set; }          // -1 表示单端口

    public PortInterval(int start, int end = -1, char intervalSeparator = '-')
    {
        Start = start;
        if (end < 0 || start == end)
        {
            End = -1;                                // 单端口:哨兵值
        }
        else
        {
            End = end;
        }
    }

    public int Compare(PortInterval source, PortInterval target)
    {
        if (source.Start == target.Start)
        {
            return source.End.CompareTo(target.End); // 双键:Start 相同比 End
        }
        else
        {
            return source.Start.CompareTo(target.Start);
        }
    }
    // CompareTo / Equals / GetHashCode 与 IPInterval 同构,此处省略
}

对比两个类能看出一个有意思的差异:IP 用 End == null 表达单地址,端口用 End == -1 表达单端口。值类型没有 null,哨兵值是唯一选择——这也是为什么 PortInterval 的 End 用语义化的 -1 而不是 0(端口 0 是合法的保留端口)。

实战效果

三个接口配齐之后,区间对象就能无缝接入 LINQ 和集合类型:

var intervals = new List<IPInterval>
{
    new IPInterval(IPAddress.Parse("10.0.0.10"), 24),   // 10.0.0.0/24
    new IPInterval(IPAddress.Parse("10.0.0.9")),        // 单地址
    new IPInterval(IPAddress.Parse("192.168.1.1"), IPAddress.Parse("192.168.1.254")),
};

// 1. 默认排序:单地址 → 区间按起始地址升序
intervals.Sort();

// 2. 去重:相同网段自动合并判定
var distinct = intervals.Distinct().ToList();

// 3. 重合检测:排序后只需相邻比较
for (int i = 1; i < intervals.Count; i++)
{
    if (intervals[i].Start.Compare(intervals[i - 1].End) <= 0)
    {
        // 记录重合关系(第 i 条与前一条区间重叠)
    }
}

重合检测的时间复杂度从 O(n²) 降到 O(n log n + n):先排序,再线性扫描。这一招在区间类问题上几乎是通用的——会议室安排、磁盘段合并、IP 段稽核,本质都是同一个模式。

总结

回顾这套 IP 区间建模的完整链路:

  1. 比较发生在字节层面:GetAddressBytes + 逐字节循环,绕开字符串字典序陷阱;协议族一致性在比较入口强制校验;
  2. CIDR 网段即区间:First/Last 位运算求网络地址与广播地址,把单 IP、范围、掩码三种写法统一成 Start-End 模型;
  3. 三接口各司其职:IComparer<T> 定义双键排序规则,IComparable<T> 委托复用,IEquatable<T> + GetHashCode 保证相等语义与哈希一致性;
  4. 同构复用:区间模型从 IP 推广到端口,值类型场景用哨兵值代替 null。

这套模式的价值不在代码量(两个类加起来不到两百行),而在于把”比较、排序、判等”三种语义收敛到一处定义,让上层业务(导入校验、重合检测、区间合并)无需关心 IP 协议细节。下次再遇到”这个字段既可能是值、也可能是区间”的需求,不妨照着这个模板走一遍。