ObjectPoolLinter 1.1.0
See the version list below for details.
dotnet add package ObjectPoolLinter --version 1.1.0
NuGet\Install-Package ObjectPoolLinter -Version 1.1.0
<PackageReference Include="ObjectPoolLinter" Version="1.1.0"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
<PackageVersion Include="ObjectPoolLinter" Version="1.1.0" />
<PackageReference Include="ObjectPoolLinter"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> </PackageReference>
paket add ObjectPoolLinter --version 1.1.0
#r "nuget: ObjectPoolLinter, 1.1.0"
#:package ObjectPoolLinter@1.1.0
#addin nuget:?package=ObjectPoolLinter&version=1.1.0
#tool nuget:?package=ObjectPoolLinter&version=1.1.0
ObjectPoolLinter
A Roslyn analyzer for Unity C# that detects object allocations in hot paths (like Update, FixedUpdate, etc.) and suggests using object pools to avoid garbage collection pressure and frame hitches.
Features
- Detects allocations in Unity hot paths: Flags
newobject allocations, structs boxed on creation (object o = new MyStruct();), andObject.Instantiate()calls inside frequently-called Unity methods - Covers 18 Unity message methods:
Update,FixedUpdate,LateUpdate,OnGUI,OnTriggerStay,OnTriggerStay2D,OnCollisionStay,OnCollisionStay2D,OnMouseOver,OnMouseDrag,OnAnimatorMove,OnAnimatorIK,OnRenderObject,OnWillRenderObject,OnPreRender,OnPostRender,OnDrawGizmos,OnDrawGizmosSelected - Code fixes: Provides quick actions to replace allocations with object pool
Get()calls or add TODO comments - Targets a specific pool shape: the replacement fix rewrites
new Enemy(hp)toEnemyPool.Get(hp), so it needs a type named{TypeName}Poolwith a staticGet, already in scope. It does not create the pool - see The pool contract
What the rule deliberately does not cover — call-graph analysis, most boxing, string and LINQ allocation, allocating Unity APIs, and the two allocation shapes with no replacement fix — is listed under Known limitations.
Requirements
The analyzer and code fix are netstandard2.0 assemblies built against Roslyn 3.8
(Microsoft.CodeAnalysis.CSharp 3.8.0), the minimum supported version. They load in any compiler or
IDE that ships Roslyn 3.8 or later.
| Host | Minimum supported | Notes |
|---|---|---|
| Unity | 2021.3 | Unity requires Roslyn plugins built against 3.8 on 2021.3 and 2022.3, and against 4.3 or lower on Unity 6, so one 3.8 build covers all three. Verified on 6000.4.6f1; 2021.3 and 2022.3 are declared by the UPM package but untested here. |
| Visual Studio | 2019 16.8 | First release carrying Roslyn 3.8. |
| .NET SDK | 5.0 | Needed only to compile a project that references the analyzer, not by Unity. |
| Rider | Current releases | Rider ships its own Roslyn; no version older than the Roslyn 3.8 era has been tested. |
| VS Code | Current releases with the C# extension (or C# Dev Kit) | Analyzer diagnostics need background analysis enabled (dotnet.backgroundAnalysis.analyzerDiagnosticsScope). |
The NuGet package has no dependencies of its own and adds nothing to your build output: it is a development-time analyzer reference only.
Hosts older than Roslyn 3.8 are not supported: the analyzer recognizes C# 9 target-typed new(),
which Roslyn 3.8 introduced.
Installation
Unity
Unity does not read NuGet packages, so the analyzer ships as Unity artifacts on the
releases page: a .unitypackage and a UPM
tarball (com.joezhuo.objectpoollinter-<version>.tgz). Both contain the same two DLLs with the
RoslynAnalyzer asset label and every platform disabled. Pick one:
.unitypackage — download it, then Assets > Import Package > Custom Package... and import.
The files land in Assets/Plugins/ObjectPoolLinter/.
UPM tarball — download it, then Window > Package Manager > + > Install package from tarball....
Keep the .tgz inside your project (a folder such as Packages/tarballs/) or somewhere every
machine on the team can reach, because Unity records the path to the file, not a copy of it.
Manual drop-in — if you would rather not use either artifact, copy ObjectPoolLinter.dll and
ObjectPoolLinter.CodeFixes.dll (from the NuGet package's analyzers/dotnet/cs/, or from
src/*/bin/Release/netstandard2.0/ after a local build) into a folder under Assets/, then for
each DLL in the Inspector:
- Under
Select platforms for plugin, clear Any Platform and leave every individual platform, including Editor, unchecked. Unity has to hand the DLL to the compiler rather than build it into a player or load it in the Editor. - Clear Validate References.
- At the bottom of the Inspector, open the label picker and add the label
RoslynAnalyzer, spelled exactly that way. - Click Apply.
Unity recompiles and OPL001 appears in the Console.
Tested on Unity 6000.4.6f1 (Unity 6), where both artifacts import cleanly and OPL001 is reported
during a batch-mode compile. The analyzer targets Roslyn 3.8, which Unity's documentation names as
the required version for Roslyn plugins on 2021.3 and 2022.3, so the UPM package declares
"unity": "2021.3" as its minimum — but 2021.3 and 2022.3 have not been verified here.
Scoping the analyzer
A Roslyn analyzer applies to the assembly definition in its own folder or in the closest folder
above it. Imported at Assets/Plugins/ObjectPoolLinter/ with no .asmdef alongside it, it applies
to Unity's predefined assemblies (Assembly-CSharp and friends). To cover your own assembly
definitions, either copy the DLLs into the folder of each .asmdef you want analyzed, or reference
the analyzer DLLs from those assembly definitions.
The UPM package contains no .asmdef either, so the packaged form also covers the predefined
assemblies.
Turning the rule off
Add dotnet_diagnostic.OPL001.severity = none to a .editorconfig at the project root, or wrap a
single allocation in #pragma warning disable OPL001.
Unity code compiled outside the editor
Projects that reference the UnityEngine assemblies but build with dotnet — CI compile checks,
test harnesses, generated .csproj files — can take the analyzer from NuGet:
dotnet add package ObjectPoolLinter
This adds it as an analyzer reference, so the rule runs on every build with no extra wiring. The
package is not yet on nuget.org; until the first tagged release, reference the projects directly
(see samples/SampleUnityCode/SampleUnityCode.csproj) or use the Unity artifacts above.
OPL001 only fires on types deriving from UnityEngine.MonoBehaviour, so a project with no
UnityEngine reference gets no diagnostics.
Building from source
The repository builds with the .NET 10 SDK. global.json pins 10.0.100 with
rollForward: latestFeature, so any installed 10.0.1xx or later feature band is used; an older SDK
fails with an error naming the required version. CI installs the SDK from the same file.
dotnet build ObjectPoolLinter.slnx -c Release
dotnet test ObjectPoolLinter.slnx -c Release
The solution includes samples/SampleUnityCode, a small MonoBehaviour compiled against Unity stubs
with the analyzer attached. Building the solution prints its OPL001 warnings; those are expected. To
check that the sample reports exactly the warnings it should, as CI does:
pwsh build/verify-sample.ps1
This SDK requirement applies only to building this repository. Projects that consume the analyzer need only the hosts listed under Requirements.
Building the Unity artifacts yourself
pwsh build/pack-unity.ps1
Writes ObjectPoolLinter-<version>.unitypackage and com.joezhuo.objectpoollinter-<version>.tgz
to artifacts/unity/. The version comes from
src/ObjectPoolLinter.Package/ObjectPoolLinter.Package.csproj unless you pass -Version <version>.
Add -SkipBuild to package the DLLs already in bin/Release instead of building first.
Releases
Pushing a v* tag runs .github/workflows/release.yml. The version comes from the tag, the release
notes come from the ## [<tag>] section of CHANGELOG.md, and the run builds, tests, and packs the
NuGet package and both Unity artifacts. From 1.0.0 on it pushes the package to nuget.org and creates
the GitHub release with the artifacts attached. 0.x tags are dry runs that only upload the files
as a workflow artifact.
Publishing uses nuget.org Trusted Publishing,
so the repository stores no API key. It needs two things set up once: a Trusted Publishing policy on
nuget.org for this repository and the release.yml workflow, and a NUGET_USER repository secret
holding the nuget.org profile name (not the email address) that owns the policy.
Usage
The analyzer runs automatically during build and in IDEs that support Roslyn analyzers (Visual Studio, VS Code with C# Dev Kit, Rider).
The rule it reports is documented in docs/rules/OPL001.md, which also covers how to change its severity or suppress it.
Its boundaries are listed under Known limitations; a clean run is not a claim that a method allocates nothing.
Code Fixes
When a diagnostic is reported, you can apply one of these quick fixes:
- Replace with object pool Get() - Replaces
new Type(args)withTypePool.Get(args) - Add pooling TODO comment - Adds a comment reminding you to use pooling (array allocations get
an array-specific comment pointing at
ArrayPool<T>.Shared; boxed structs get one about avoiding the box)
Constructor arguments are forwarded to Get() unchanged, so new Enemy(hp) becomes
EnemyPool.Get(hp). The pool itself is yours to write: the fix is only offered when a matching pool
type is already in scope, and it never creates one. The pool contract below
spells out exactly what "matching" means and includes a minimal pool you can copy.
The fix is not offered when the allocation carries an object or collection initializer
(new Enemy { Hp = 5 }, new List<int> { 1, 2 }), because an initializer cannot be attached to a
method call and dropping it would silently lose code. Use the TODO-comment fix there and rewrite the
initializer by hand.
It is also not offered for array allocations (new int[4], new[] { 1, 2 }). Those are still
reported, but there is no safe mechanical replacement: ArrayPool<T>.Shared.Rent(4) returns an array
of length at least 4 rather than exactly 4, and the buffer has to be returned on every exit path.
Only the TODO-comment fix is offered, and
docs/rules/OPL001.md covers the ways to fix an array allocation by
hand.
The pool contract
The Replace with object pool Get() fix does not write a pool for you. It rewrites the
allocation and nothing else, and it is only offered when a pool matching the contract below is
already in scope. When no such type exists, the fix is not offered at all and only the TODO-comment
fix appears.
A pool satisfies the contract when all of these hold at the allocation site:
| Requirement | Detail |
|---|---|
| Name | Exactly {TypeName}Pool, where TypeName is the unqualified name of the allocated type. new Enemy() looks for EnemyPool; new System.Collections.Generic.List<int>() looks for ListPool, never System.Collections.Generic.ListPool. |
| Visibility | Resolvable by simple name at the allocation site: the same namespace, an enclosing type, or a namespace already imported by a using. The fix never adds a using and never qualifies the name it writes. |
| Generic arity | Must match the allocated type's type-argument count, and the type arguments are copied over. new List<int>() becomes ListPool<int>.Get(), so ListPool has to be declared as ListPool<T>. |
Get member |
A static method named exactly Get, accessible from the allocation site. An instance Get on a pool field, a singleton or an ObjectPool<T> instance does not qualify. |
Get arity |
Must accept the constructor's argument count, because arguments are forwarded unchanged: new Enemy(hp) needs a Get overload taking one argument. Optional parameters and params are honoured. |
Two things the fix does not check:
- The return type. A
Getthat returns something other than the allocated type still satisfies the lookup, and the rewritten call will not compile. That is a loud failure, not a silent one. - The lifetime. Nothing is inserted to hand the object back. Writing the release call is yours to do; a pool nothing is returned to is a leak with extra steps.
Target-typed new() is handled the same way: the pool name comes from the type the expression is
converted to, so Enemy e = new(); looks for EnemyPool just as new Enemy() does.
A minimal pool
Copy-pasteable, C# 7.3, nothing beyond System.Collections.Generic, so it compiles in Unity 2021.3
and later as-is. It is not thread-safe, which is enough for the main-thread Unity messages this rule
watches.
using System.Collections.Generic;
public static class EnemyPool
{
private static readonly Stack<Enemy> Free = new Stack<Enemy>();
// Matches new Enemy(hp): one argument, forwarded unchanged.
public static Enemy Get(int hp)
{
if (Free.Count == 0) return new Enemy(hp);
var enemy = Free.Pop();
enemy.Hp = hp; // reset whatever the constructor would have set
return enemy;
}
public static void Release(Enemy enemy)
{
Free.Push(enemy);
}
}
Resetting a recycled instance is the pool's job, not the analyzer's. Get has to return an object in
the state the constructor would have produced, or pooling introduces bugs the allocation never had.
The generic form, which turns new List<int>() into ListPool<int>.Get():
using System.Collections.Generic;
public static class ListPool<T>
{
private static readonly Stack<List<T>> Free = new Stack<List<T>>();
public static List<T> Get()
{
return Free.Count > 0 ? Free.Pop() : new List<T>();
}
public static void Release(List<T> list)
{
list.Clear();
Free.Push(list);
}
}
Pools you may already have
Unity 2021.1 and later ship the UnityEngine.Pool namespace, whose ListPool<T>, HashSetPool<T>
and DictionaryPool<TKey, TValue> each expose a static Get() and so match the contract by name and
arity. With using UnityEngine.Pool; in the file, the fix rewrites new List<int>() to
ListPool<int>.Get() against Unity's own pool; release with ListPool<int>.Release(list).
UnityEngine.Pool.ObjectPool<T> does not match: its Get is an instance method, and the type is not
named {TypeName}Pool for any pooled type. Use it by hand, or wrap it in a static class named for
the type you are pooling.
Known limitations
OPL001 is deliberately narrow: it reports allocations it can see in a hot-path body and offers a fix only when that fix cannot change behaviour. The gaps below are known and intentional for this version, not bugs.
Only allocations written directly in the message body are reported. There is no call-graph
analysis, so void Update() { Spawn(); } is silent no matter what Spawn() allocates. An allocation
inside a lambda or an anonymous method is attributed to whoever invokes the delegate, not to the
message, so it is reported only when the lambda is invoked in place; a local function is reported
only when the declaring body actually calls it. Converting either to a delegate — registering it as a
callback — escapes the rule on purpose, because how often it runs is no longer decided by the frame.
Several allocating constructs are not detected at all. The analyzer matches new expressions and
UnityEngine.Object.Instantiate calls. It does not flag string concatenation or interpolation,
closure capture, implicit params arrays, LINQ operators, or the allocating Unity APIs people hit
most in Update (GetComponentsInChildren, Physics.RaycastAll, GameObject.Find,
Camera.allCameras, Input.touches). Boxing is caught only when a struct is boxed as it is created
(object o = new MyStruct();, Consume((IShape)new Circle())); boxing an existing value
(object o = count;), new int?() (which boxes to null), and calls to non-overridden object
methods on a struct are not reported. Covering these is planned as
separate rules (OPL002+), not as a widening of OPL001 — a clean OPL001 run is not a claim that a
method is allocation-free.
Array allocations get no replacement fix. new int[4] and new[] { 1, 2 } are reported, but only
the TODO-comment fix is offered, because ArrayPool<T>.Shared.Rent(4) returns an array of length at
least 4 and the buffer must be returned on every exit path. See
docs/rules/OPL001.md for the three by-hand fixes.
Allocations with an initializer get no replacement fix. new Enemy { Hp = 5 } and
new List<int> { 1, 2 } are reported, but an initializer cannot be carried onto a method call, so the
fix would have to drop it. Rewrite the initializer by hand after taking the object from the pool.
The replacement fix never writes the pool and never releases the object. It is offered only when a pool matching the pool contract is already in scope, and it inserts no release call — returning the object is yours to do.
License
MIT - see LICENSE.
Learn more about Target Frameworks and .NET Standard.
This package has no dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.