Инкапсуляция и модификаторы доступа в C#: что такое internal, private, protected

Инкапсуляция и модификаторы доступа в C#: что такое internal, private, protected Полезное

Модификатор доступа в C# — ключевое слово (public, private, protected, internal и их сочетания), которое указывает, из какого кода можно обратиться к типу или члену класса. Инкапсуляция — принцип ООП, ради которого модификаторы существуют: объект прячет внутреннее состояние и дает менять его только через свои методы и свойства, которые проверяют правила.

Короткий ответ на частый вопрос: 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 защищает от случайных ошибок и фиксирует контракт класса, но не заменяет проверок прав, хранения секретов вне кода и валидации на сервере.

Как выбрать модификатор

Практичный порядок — начинать с самого узкого уровня и расширять только по необходимости:

  1. Поле — почти всегда private, наружу — свойство или метод.
  2. Нужно наследникам — protected; только наследникам своей библиотеки — private protected.
  3. Нужно другим классам своего проекта, но не пользователям библиотеки — internal.
  4. Часть публичного API — public. Каждый такой член потом сложно изменить без поломки чужого кода.
  5. Для тестов не открывайте 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.

OTUS Журнал
Скидка 5% 12-19 октября на курсы (popup)
Бесплатные открытые уроки (поп-ап)