Модификатор доступа в C# — ключевое слово (public, private, protected, internal и их сочетания), которое указывает, из какого кода можно обратиться к типу или члену класса. Инкапсуляция — принцип ООП, ради которого модификаторы существуют: объект прячет внутреннее состояние и дает менять его только через свои методы и свойства, которые проверяют правила.
Содержание
- Мини-словарь: сборка, файл, пространство имен, наследник
- Все модификаторы доступа C# в одной таблице
- Пример на двух проектах: как работает internal
- Модификаторы по умолчанию
- Ошибки, которые встречаются чаще всего
- internal и тесты: InternalsVisibleTo
- file: доступ в пределах одного файла
- Инкапсуляция через свойства
- Инкапсуляция — не защита от взлома
- Как выбрать модификатор
- Выводы
- Где применяется / связь с практикой
- FAQ
Короткий ответ на частый вопрос: internal в C# открывает тип или член всему коду той же сборки (одного скомпилированного .dll или .exe, обычно это один проект) и прячет его от других сборок. Не файла и не пространства имен, а именно сборки.
Примеры проверены на .NET SDK 10.0.401 (C# 14) 24 сентября 2026 года, тексты ошибок взяты из реального dotnet build.
Мини-словарь: сборка, файл, пространство имен, наследник
Модификаторы путают потому, что путают границы, которые они проводят. Четыре понятия, которые встретятся ниже:
| Понятие | Что это | Пример |
|---|---|---|
| Файл | Один исходник .cs |
Account.cs |
| Пространство имен | Логическая группа имен, не граница доступа | namespace Shop.Core; |
| Сборка | Результат компиляции проекта: .dll или .exe |
Shop.Core.dll |
| Наследник | Класс, производный от данного (class B : A) |
SavingsAccount : Account |
Пространство имен на доступ не влияет вообще: internal-класс из Shop.Core виден и в Shop.Core.Reports, если это та же сборка, и не виден в Shop.App, даже если там написать using Shop.Core;.
Все модификаторы доступа C# в одной таблице
В C# шесть уровней доступа для членов классов и седьмой, file, только для типов (с C# 11). Колонки — откуда идет обращение.
| Модификатор | Свой тип | Наследник, та же сборка | Не наследник, та же сборка | Наследник, другая сборка | Не наследник, другая сборка |
|---|---|---|---|---|---|
public |
да | да | да | да | да |
protected internal |
да | да | да | да | нет |
protected |
да | да | нет | да | нет |
internal |
да | да | да | нет | нет |
private protected |
да | да | нет | нет | нет |
private |
да | нет | нет | нет | нет |
file (только типы) |
только код в том же .cs-файле |
Две пары путают чаще всего:
protected internal= protected ИЛИ internal. Доступ есть у всей своей сборки плюс у наследников в любой сборке. Это самый широкий уровень послеpublic.private protected= protected И internal. Доступ есть только у наследников, и только если они в той же сборке (появился в C# 7.2).
Пример на двух проектах: как работает internal
Чтобы увидеть границу сборки, нужны два проекта: библиотека и приложение, которое на нее ссылается.
dotnet new classlib -n Shop.Core
dotnet new console -n Shop.App
dotnet add Shop.App reference Shop.Core/Shop.Core.csproj
Файл библиотеки Shop.Core/Account.cs — счет, у которого каждый член помечен своим модификатором:
namespace Shop.Core;
public class Account
{
private decimal _balance; // только внутри Account
internal string AuditTag = "core"; // любой код сборки Shop.Core
protected int Operations; // Account и наследники (из любой сборки)
protected internal int Version = 1; // наследники ИЛИ любой код Shop.Core
private protected bool Frozen; // наследники, но только внутри Shop.Core
public decimal Balance => _balance; // читать может кто угодно
public void Deposit(decimal amount)
{
if (amount <= 0)
throw new ArgumentOutOfRangeException(nameof(amount), "Сумма должна быть больше нуля");
if (Frozen)
throw new InvalidOperationException("Счет заморожен");
_balance += amount;
Operations++;
}
}
internal class FeeCalculator // тип виден только в Shop.Core
{
public decimal Fee(decimal amount) => amount * 0.01m;
}
public class SavingsAccount : Account // наследник в той же сборке
{
public void Freeze() => Frozen = true; // private protected: можно
}
Теперь неверный вариант Shop.App/Program.cs, который пытается дотянуться до всего подряд из другой сборки:
using Shop.Core;
var acc = new Account();
acc.Deposit(100);
Console.WriteLine(acc.Balance);
Console.WriteLine(acc._balance); // private
Console.WriteLine(acc.AuditTag); // internal
Console.WriteLine(acc.Version); // protected internal, но App не наследник
var calc = new FeeCalculator(); // internal-тип
class VipAccount : Account // наследник в ДРУГОЙ сборке
{
public int Ops() => Operations; // protected: можно
public int Ver() => Version; // protected internal: можно (наследник)
public void Stop() => Frozen = true; // private protected: нельзя
}
Результат dotnet build Shop.App (пути сокращены):
Shop.App/Program.cs(7,23): error CS1061: 'Account' does not contain a definition for '_balance' and no accessible extension method '_balance' accepting a first argument of type 'Account' could be found (are you missing a using directive or an assembly reference?)
Shop.App/Program.cs(8,23): error CS1061: 'Account' does not contain a definition for 'AuditTag' and no accessible extension method 'AuditTag' accepting a first argument of type 'Account' could be found (are you missing a using directive or an assembly reference?)
Shop.App/Program.cs(9,23): error CS0122: 'Account.Version' is inaccessible due to its protection level
Shop.App/Program.cs(10,16): error CS0122: 'FeeCalculator' is inaccessible due to its protection level
Shop.App/Program.cs(16,27): error CS0103: The name 'Frozen' does not exist in the current context
Что здесь видно:
Operations(protected) иVersion(protected internal) внутри наследникаVipAccountдоступны, хотя он в другой сборке. Ошибок на строках 14-15 нет.Frozen(private protected) наследнику из другой сборки недоступен:SavingsAccountвShop.Coreего менять может,VipAccountвShop.App— нет.- Для
privateиinternalчленов чужой сборки компилятор в этом прогоне пишет не «недоступен» (CS0122), а «не содержит определения» (CS1061): из скомпилированной чужой сборки он такие члены не загружает. ДляFrozenошибка CS0103 появилась потому, что SDK по умолчанию собирает для ссылок урезанную референсную сборку безprivate protectedчленов; с-p:ProduceReferenceAssembly=falseта же строка дает CS0122. Если увидели CS1061 или CS0103 на члене, который точно есть в классе, проверьте его модификатор.
Исправление — обращаться только через открытый контракт:
using Shop.Core;
var acc = new Account();
acc.Deposit(100);
Console.WriteLine($"Баланс: {acc.Balance}");
var vip = new VipAccount();
vip.Deposit(50);
vip.Deposit(25);
Console.WriteLine($"Операций у VIP: {vip.Ops()}, версия: {vip.Ver()}");
try { acc.Deposit(-5); }
catch (ArgumentOutOfRangeException e) { Console.WriteLine(e.Message); }
class VipAccount : Account
{
public int Ops() => Operations;
public int Ver() => Version;
}
dotnet run --project Shop.App печатает:
Баланс: 100
Операций у VIP: 2, версия: 1
Сумма должна быть больше нуля (Parameter 'amount')
Отрицательную сумму не пропустил Deposit, а записать в _balance напрямую снаружи нельзя — в этом и состоит инкапсуляция.
Модификаторы по умолчанию
Если модификатор не указан, действует умолчание. Оно зависит от того, где объявлен элемент:
| Что объявлено | По умолчанию | Что еще разрешено |
|---|---|---|
| Класс, структура, интерфейс, enum, record верхнего уровня | internal |
public, file |
| Член класса (поле, метод, свойство) | private |
все шесть уровней |
| Член структуры | private |
public, internal, private |
| Вложенный тип | private |
как у членов |
| Член интерфейса | public |
с C# 8 можно указывать другие |
| Элемент enum | public |
модификаторы не указываются |
Отсюда практичное следствие: class Helper { } без слова public уже internal, и из другого проекта его не видно.
Ошибки, которые встречаются чаще всего
Все четыре случая проверены сборкой на .NET 10.
Тип верхнего уровня нельзя сделать private. Код private class TopLevel { } в пространстве имен дает CS1527: Elements defined in a namespace cannot be explicitly declared as private, protected, protected internal, or private protected. Нужен internal (или file, если тип нужен только в одном файле).
У структуры нет protected. Структуры не наследуются, поэтому struct Point { protected int X; } дает CS0666: 'Point.X': new protected member declared in struct.
protected работает только через свой тип. Наследник видит защищенный член у себя и у объектов своего типа, но не у произвольного объекта базового класса:
class Base { protected int Secret = 42; }
class Derived : Base
{
int Peek(Base other) => other.Secret; // ошибка
int Mine(Derived other) => other.Secret; // можно
}
error CS1540: Cannot access protected member 'Base.Secret' via a qualifier of type 'Base'; the qualifier must be of type 'Derived' (or derived from it)
Открытый метод не может отдавать менее доступный тип. Если Order помечен internal, а метод public Order Create() лежит в public-классе, будет CS0050: Inconsistent accessibility: return type 'Order' is less accessible than method 'OrderService.Create()'. Решение — либо открыть Order, либо сузить метод до internal.
internal и тесты: InternalsVisibleTo
Типичная ситуация: логика в internal-классе, а тесты лежат в отдельном проекте, то есть в другой сборке. Чтобы не открывать класс всем ради тестов, сборка может явно доверить свои internal-члены конкретной другой сборке. В SDK-проекте это одна строка в Shop.Core.csproj:
<ItemGroup>
<InternalsVisibleTo Include="Shop.App" />
</ItemGroup>
После этого Shop.App видит acc.AuditTag и new FeeCalculator(): в прогоне new FeeCalculator().Fee(200) вернул 2.00. В реальном проекте в Include пишут имя тестовой сборки, например Shop.Core.Tests. Тот же эффект дает атрибут [assembly: InternalsVisibleTo("Shop.Core.Tests")] в любом файле библиотеки.
file: доступ в пределах одного файла
Модификатор file (C# 11) — единственный, который действительно ограничивает доступ файлом. Он применим только к типам, не к членам:
// Helpers.cs
file class Cache { public static int Hits; }
class InSameFile { int H() => Cache.Hits; } // можно
// Other.cs
class InOtherFile { int H() => Cache.Hits; } // CS0103: The name 'Cache' does not exist in the current context
Используется в основном в генераторах кода, чтобы вспомогательные типы из разных файлов не конфликтовали по именам. В обычном коде чаще хватает internal или вложенного private-класса.
Инкапсуляция через свойства
Модификатор отвечает на вопрос «кто может обратиться», свойство — на вопрос «что при этом разрешено». Поле делают private, а наружу выставляют свойство с нужным уровнем записи:
var c = new Customer("Анна") { Email = "anna@example.com" };
c.Age = 30;
Console.WriteLine($"{c.Name}, {c.Age}, {c.Email}, заказов: {c.Orders}");
c.AddOrder();
Console.WriteLine($"Заказов: {c.Orders}");
try { c.Age = 150; }
catch (ArgumentOutOfRangeException e) { Console.WriteLine(e.Message); }
// c.Name = "Олег"; // CS0200: свойство только для чтения
// c.Orders = 10; // CS0272: set недоступен (private)
// c.Email = "x"; // CS8852: init-свойство меняется только при создании
public class Customer
{
public Customer(string name) => Name = name;
public string Name { get; } // только чтение, задается в конструкторе
public string Email { get; init; } = ""; // задается один раз при создании
public int Orders { get; private set; } // читать всем, менять только классу
public int Age // C# 14: ключевое слово field
{
get;
set => field = value is >= 0 and <= 120
? value
: throw new ArgumentOutOfRangeException(nameof(value), "Возраст от 0 до 120");
}
public void AddOrder() => Orders++;
}
Вывод:
Анна, 30, anna@example.com, заказов: 0
Заказов: 1
Возраст от 0 до 120 (Parameter 'value')
Если раскомментировать три строки в середине, сборка остановится с ошибками CS0200, CS0272 и CS8852 — по одной на каждый способ ограничить запись. Ключевое слово field внутри аксессора ссылается на скрытое поле, которое создает компилятор; оно стало частью языка в C# 14 (.NET 10). На более старых версиях то же самое пишут явным полем private int _age; и set { ... _age = value; }.
Инкапсуляция — не защита от взлома
Модификаторы проверяет компилятор, а не среда выполнения против злоумышленника. Через рефлексию private-поле можно прочитать и изменить:
using System.Reflection;
using Shop.Core;
var acc = new Account();
acc.Deposit(100);
var f = typeof(Account).GetField("_balance", BindingFlags.NonPublic | BindingFlags.Instance)!;
f.SetValue(acc, 1_000_000m);
Console.WriteLine(acc.Balance); // 1000000
Поэтому private защищает от случайных ошибок и фиксирует контракт класса, но не заменяет проверок прав, хранения секретов вне кода и валидации на сервере.
Как выбрать модификатор
Практичный порядок — начинать с самого узкого уровня и расширять только по необходимости:
- Поле — почти всегда
private, наружу — свойство или метод. - Нужно наследникам —
protected; только наследникам своей библиотеки —private protected. - Нужно другим классам своего проекта, но не пользователям библиотеки —
internal. - Часть публичного API —
public. Каждый такой член потом сложно изменить без поломки чужого кода. - Для тестов не открывайте
internalдоpublic, используйтеInternalsVisibleTo.
Для тех, кто пришел из Java: там нет internal, а уровень без модификатора — package-private (доступ в пределах пакета). Java-protected тоже шире: он включает весь пакет. Ближайший аналог Java-пакета в C# по смыслу доступа — сборка, а не пространство имен.
Выводы
internalоткрывает тип или член всей своей сборке (.dll/.exe) и скрывает от других сборок; файл и пространство имен тут ни при чем.protected internal— это protected ИЛИ internal,private protected— protected И internal;fileограничивает тип одним исходным файлом.- Без модификатора класс верхнего уровня —
internal, член класса или структуры —private. - Инкапсуляцию дают
private-поля плюс свойства сprivate set,initи проверкой вset. - Модификаторы проверяет компилятор; рефлексия их обходит, поэтому это инструмент дизайна, а не безопасности.
Где применяется / связь с практикой
Освойте тему на практике
Модификаторы доступа начинают по-настоящему работать, когда в решении появляется несколько проектов: библиотека, API, тесты. Тогда от выбора между internal и public зависит, что станет публичным контрактом, а что можно свободно менять. Эти вещи разбирают на практике в курсе C#-разработки с нуля, а посмотреть формат занятий можно на бесплатных открытых уроках Otus.
FAQ
static — это модификатор доступа?
Нет. static означает, что член принадлежит типу, а не объекту, и сочетается с любым уровнем доступа: private static, public static и так далее.
Можно ли сделать свойство публичным для чтения и internal для записи?
Да: public int Count { get; internal set; }. Модификатор аксессора должен быть строже, чем у самого свойства.
Если два проекта в одном решении, internal между ними виден?
Нет. Решение (.sln) — просто набор проектов, а каждый проект собирается в свою сборку. Доступ дает только InternalsVisibleTo.



