一个真实的排序 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); // 广播地址
}
}
}
注意两个设计细节:
End == null是一个语义信号,表示”单地址”或”/32 单主机网段”,而不是没有结束地址;- 构造函数即校验:
end < start直接抛异常,保证区间对象创建出来就是合法的——这符合”让非法状态不可表示”的设计原则。
第三步:三个泛型接口定义完整的比较语义
这是本文的核心知识点。一个”可比较、可排序、可判等”的区间类型,需要同时实现三个接口:
flowchart TD
A["IComparable<T><br/>CompareTo: 默认排序"] --> D["List.Sort()<br/>OrderBy / SortedSet"]
B["IComparer<T><br/>Compare: 双键排序"] --> D
B --> E["显式指定比较器<br/>Array.Sort(arr, comparer)"]
C["IEquatable<T><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 区间建模的完整链路:
- 比较发生在字节层面:
GetAddressBytes+ 逐字节循环,绕开字符串字典序陷阱;协议族一致性在比较入口强制校验; - CIDR
网段即区间:
First/Last位运算求网络地址与广播地址,把单 IP、范围、掩码三种写法统一成Start-End模型; - 三接口各司其职:
IComparer<T>定义双键排序规则,IComparable<T>委托复用,IEquatable<T>+GetHashCode保证相等语义与哈希一致性; - 同构复用:区间模型从 IP 推广到端口,值类型场景用哨兵值代替 null。
这套模式的价值不在代码量(两个类加起来不到两百行),而在于把”比较、排序、判等”三种语义收敛到一处定义,让上层业务(导入校验、重合检测、区间合并)无需关心 IP 协议细节。下次再遇到”这个字段既可能是值、也可能是区间”的需求,不妨照着这个模板走一遍。