
嗨,大家都很喜欢异国和普通bug的收藏家! 今天,我们的PVS-Studio测试台上有一个罕见的标本-用C#编写的名为“ osu!”的游戏。 像往常一样,我们将寻找错误,对其进行分析和播放。
游戏
大须! 是一款开源节奏游戏。 根据该游戏的
网站 ,它非常受欢迎,拥有超过1500万玩家帐户。 该项目具有免费的游戏玩法,色彩缤纷的设计,地图定制,先进的在线玩家排名系统,多人游戏模式以及丰富的音乐作品。 没有必要进一步阐述游戏了。 您可以在Internet上阅读有关它的所有信息。
从此页面开始。
我对项目的源代码更感兴趣,该源代码可在
GitHub上
找到 。 立即引起您注意的一件事是,大量的存储库提交(超过2万4千次),这表明正在持续进行紧张的开发(该游戏于2007年首次发布,但工作必须更早开始)。 但是,该项目并不大:只有1813个.cs文件,总共有13.5万个非空LOC。 该数字还包括测试,运行检查时通常不会考虑这些测试。 测试组成了306个.cs文件,具有2.5万个LOC。 该项目确实很小:例如,PVS-Studio的C#内核大约有30万个LOC。
除去测试文件,我检查了1105个LOC长的1507个文件。 检查确实发现了一些有趣的错误,我愿意向您展示。
虫子
V3001在'||'的左侧和右侧有相同的子表达式'result == HitResult.Perfect' 操作员。 DrawableHoldNote.cs 266
protected override void CheckForResult(....) { .... ApplyResult(r => { if (holdNote.hasBroken && (result == HitResult.Perfect || result == HitResult.Perfect)) result = HitResult.Good; .... }); }
这是面向复制粘贴编程的一个很好的例子,这是我的同事Valeriy Komarov最近在他的文章“
2019年在Java项目中发现的十大错误 ”中使用的一个幽默术语。
无论如何,连续执行两个相同的检查。 其中之一可能是要检查
HitResult枚举的其他常量:
public enum HitResult { None, Miss, Meh, Ok, Good, Great, Perfect, }
应该检查哪个常数? 也许第二张支票根本不存在? 这些是只有作者才能回答的问题。 无论如何,这是一个错误,会扭曲程序的执行逻辑。
V3001在'&&'运算符的左侧和右侧有相同的子表达式'family!= GetFamilyString(TournamentTypeface.Aquatico)'。 TournamentFont.cs 64
public static string GetWeightString(string family, FontWeight weight) { .... if (weight == FontWeight.Regular && family != GetFamilyString(TournamentTypeface.Aquatico) && family != GetFamilyString(TournamentTypeface.Aquatico)) weightString = string.Empty; .... }
再次复制粘贴。 我重构了代码,因此现在很容易注意到该错误,但最初它是用一行编写的。 就像在前面的示例中一样,我无法确定应该如何精确地解决此问题。
TournamentTypeface枚举仅包含一个常数:
public enum TournamentTypeface { Aquatico }
也许错误是关于两次检查
家庭变量的,但我可能是错的。
V3009 [CWE-393]奇怪的是,此方法始终返回一个相同的'false'值。 KeyCounterAction.cs 19
public bool OnPressed(T action, bool forwards) { if (!EqualityComparer<T>.Default.Equals(action, Action)) return false; IsLit = true; if (forwards) Increment(); return false; }
每次此方法返回
false 。 在这种情况下,我通常会检查函数调用,因为您可能经常发现调用者未使用返回值,这意味着没有问题(除了不良样式)。 在这种情况下,呼叫如下所示:
public bool OnPressed(T action) => Target.Children .OfType<KeyCounterAction<T>>() .Any(c => c.OnPressed(action, Clock.Rate >= 0));
如您所见,调用者确实使用了
OnPressed方法返回的值。 由于该值始终为
false ,因此调用者本身也总是返回
false 。 此代码很可能包含错误,应进行修订。
另一个类似的错误:
- V3009 [CWE-393]奇怪的是,此方法始终返回一个相同的'false'值。 KeyCounterAction.cs 30
V3042 [CWE-476]可能为NullReferenceException。 '?。' 和“。” 运算符用于访问“ val.NewValue”对象TournamentTeam.cs的成员41
public TournamentTeam() { Acronym.ValueChanged += val => { if (....) FlagName.Value = val.NewValue.Length >= 2
在
?:运算符的情况下,以危险的方式处理
val.NewValue变量。 使分析器如此认为的事实是,在
随后的then分支中,使用条件访问运算符
val.NewValue?.Substring(....)以安全的方式处理了相同的变量。
另一个类似的错误:
- V3042 [CWE-476]可能为NullReferenceException。 '?。' 和“。” 运算符用于访问“ val.NewValue”对象TournamentTeam.cs的成员48
V3042 [CWE-476]可能为NullReferenceException。 '?。' 和“。” 运算符用于访问“ api”对象SetupScreen.cs的成员77
private void reload() { .... new ActionableInfo { Label = "Current User", ButtonText = "Change Login", Action = () => { api.Logout();
这个比较不明确,但是我相信这也是一个错误。 程序员创建一个
ActionableInfo类型的对象。 使用lambda函数初始化
Action字段,该函数以危险的方式处理可能为空的引用
api 。 分析器认为此模式是错误的,因为在初始化
Value参数时,以后会以安全的方式处理
api变量。 我之所以称这种情况为模棱两可,是因为lambda函数中的代码暗含了执行延迟,此时开发人员可能会以某种方式保证
api引用为非null。 但是我不确定,因为lambda函数的主体似乎未使用任何安全的引用处理,例如先前的检查。
V3066 [CWE-683]传递给“ Atan2”方法的参数的可能错误顺序:“ diff.X”和“ diff.Y”。 SliderBall.cs 182
public void UpdateProgress(double completionProgress) { .... Rotation = -90 + (float)(-Math.Atan2(diff.X, diff.Y) * 180 / Math.PI); .... }
分析器怀疑
Atan2方法的参数以错误的顺序传递。 这是方法的声明:
值以相反的顺序传递。 我不确定这是否是一个错误,因为
UpdateProgress方法包含很多非平凡的计算; 我只是将其提及为可能的错误。
V3080 [CWE-476]可能的空解除引用。 考虑检查“ Beatmap”。 WorkingBeatmap.cs 57
protected virtual Track GetVirtualTrack() { .... var lastObject = Beatmap.HitObjects.LastOrDefault(); .... }
分析器指出
Beatmap可能会取消引用:
public IBeatmap Beatmap { get { try { return LoadBeatmapAsync().Result; } catch (TaskCanceledException) { return null; } } }
好吧,分析仪是正确的。
要了解有关PVS-Studio如何检测到此类错误的更多信息,以及有关C#8.0中添加的与处理可能为空的引用有关的新功能的更多信息,请参阅文章“
C#8.0中的可空引用类型和静态分析 ”。
V3083 [CWE-367]事件'ObjectConverted'的不安全调用,可能会发生NullReferenceException。 请考虑在调用事件之前将事件分配给局部变量。 BeatmapConverter.cs 82
private List<T> convertHitObjects(....) { .... if (ObjectConverted != null) { converted = converted.ToList(); ObjectConverted.Invoke(obj, converted); } .... }
这是次要且相当常见的错误。 订户可能会取消订阅在空检查和事件调用之间的事件,从而导致崩溃。 这是修复该错误的一种方法:
private List<T> convertHitObjects(....) { .... converted = converted.ToList(); ObjectConverted?.Invoke(obj, converted); .... }
V3095 [CWE-476]在验证是否为null之前,已使用“列”对象。 检查线:141,142。SquareGraph.cs 141
private void redrawProgress() { for (int i = 0; i < ColumnCount; i++) columns[i].State = i <= progress ? ColumnState.Lit : ColumnState.Dimmed; columns?.ForceRedraw(); }
列集合上的迭代以危险的方式进行。 开发人员假定
列引用可以为空,这通过使用条件访问运算符来进一步访问代码中的集合来指示。
V3119调用重写的事件'OnNewResult'可能导致不可预测的行为。 考虑显式实现事件访问器或使用“ sealed”关键字。 DrawableRuleset.cs 256
private void addHitObject(TObject hitObject) { .... drawableObject.OnNewResult += (_, r) => OnNewResult?.Invoke(r); .... } public override event Action<JudgementResult> OnNewResult;
分析仪说使用覆盖事件或虚拟事件很危险。 请参阅诊断
说明以获取解释。 我还写了一篇有关该主题的文章:“
C#中的虚拟事件:出了点问题 ”。
这是另一个类似的不安全构造:
- V3119调用重写的事件可能导致不可预测的行为。 考虑显式实现事件访问器或使用“ sealed”关键字。 DrawableRuleset.cs 257
V3123 [CWE-783]也许是“ ??” 操作员的工作方式与预期不同。 它的优先级低于其左侧的其他运营商的优先级。 OsuScreenStack.cs 45
private void onScreenChange(IScreen prev, IScreen next) { parallaxContainer.ParallaxAmount = ParallaxContainer.DEFAULT_PARALLAX_AMOUNT * ((IOsuScreen)next)?.BackgroundParallaxAmount ?? 1.0f; }
为了更好地理解,下面是一个综合示例,演示了此代码的原始逻辑:
x = (c * a) ?? b;
该错误源于以下事实:“ *”运算符的优先级高于“ ??”的优先级 操作员。 固定代码如下所示(添加了括号):
private void onScreenChange(IScreen prev, IScreen next) { parallaxContainer.ParallaxAmount = ParallaxContainer.DEFAULT_PARALLAX_AMOUNT * (((IOsuScreen)next)?.BackgroundParallaxAmount ?? 1.0f); }
另一个类似的错误:
V3123 [CWE-783]也许是“ ??” 操作员的工作方式与预期不同。 它的优先级低于其左侧的其他运营商的优先级。 FramedReplayInputHandler.cs 103
private bool inImportantSection { get { .... return IsImportant(frame) && Math.Abs(CurrentTime - NextFrame?.Time ?? 0) <= AllowedImportantTimeSpan; } }
与前面的情况一样,程序员对运算符的优先级有错误的假设。 传递给
Math.Abs方法的原始表达式的评估如下:
(a – b) ?? 0
固定方法如下:
private bool inImportantSection { get { .... return IsImportant(frame) && Math.Abs(CurrentTime – (NextFrame?.Time ?? 0)) <= AllowedImportantTimeSpan; } }
V3142 [CWE-561]未检测到代码。 可能存在错误。 DrawableHoldNote.cs 214
public override bool OnPressed(ManiaAction action) { if (!base.OnPressed(action)) return false; if (Result.Type == HitResult.Miss)
分析器认为,从第二个
if语句开始,
OnPressed处理程序的代码不可访问。 这是因为第一个条件始终为true,即
base.OnPressed方法将始终返回
false 。 让我们看一下
base.OnPressed方法:
public virtual bool OnPressed(ManiaAction action) { if (action != Action.Value) return false; return UpdateResult(true); }
现在在
UpdateResult方法中:
protected bool UpdateResult(bool userTriggered) { if (Time.Elapsed < 0) return false; if (Judged) return false; .... return Judged; }
请注意,此处的
Judged属性的实现无关紧要,因为
UpdateResult方法的逻辑意味着最后的
return语句等效于以下内容:
return false;
这意味着
UpdateResult方法将一直返回
false ,从而导致堆栈中较早的代码无法到达。
V3146 [CWE-476]可能对'规则集'进行空引用。 “ FirstOrDefault”可以返回默认的空值。 APILegacyScoreInfo.cs 24
public ScoreInfo CreateScoreInfo(RulesetStore rulesets) { var ruleset = rulesets.GetRuleset(OnlineRulesetID); var mods = Mods != null ? ruleset.CreateInstance()
分析器认为
ruleset.CreateInstance()调用不安全。 在此调用之前,由于调用
GetRuleset方法而为
规则集变量分配了一个值:
public RulesetInfo GetRuleset(int id) => AvailableRulesets.FirstOrDefault(....);
如您所见,警告是有效的,因为调用序列包括
FirstOrDefault方法,该方法可以返回
null 。
结论
“ osu!”代码中没有很多错误,这很好。 但是我仍然建议作者检查分析仪报告的问题。 我希望这将有助于保持高质量,并且游戏将继续为玩家带来欢乐。
提醒一下,如果您喜欢修改源代码,PVS-Studio是一个不错的选择。 该分析仪可在官方网站上
下载 。 我还要记住的另一件事是,像这样的一次性检查与实际开发过程中静态分析的正常使用无关。 只有在构建服务器和开发人员的计算机上定期使用此方法时才最有效(这称为增量分析)。 您的最终目标是在编码阶段捕获错误,以防止错误进入版本控制系统。
祝你好运,并保持创造力!
参考文献
这是我们在2020年发表的第一篇文章。在撰写本文时,这是过去一年中对C#项目进行检查的链接: