Основы C#

Типы данных в C#

Обновлено: · 13 мин чтения

Типы данных определяют, какие значения может хранить переменная и сколько памяти она занимает. Ниже собраны все основные типы C#: значимые и ссылочные, целые и вещественные, decimal, bool, char, string, object и dynamic.

Вы увидите таблицу диапазонов, примеры работы с var, nullable-типами и преобразованиями, а также узнаете, как определить тип через typeof и GetType.

Типы данных в C#
Содержание
  1. Система типов в C#
  2. Значимые и ссылочные типы
    1. Значимые типы
    2. Ссылочные типы
  3. Целочисленные типы
  4. Вещественные типы и decimal
    1. Погрешность чисел с плавающей точкой
    2. Когда выбирать decimal
  5. Логический и символьный типы
    1. Тип bool
    2. Тип char
  6. Строки и тип string
  7. Типы object, dynamic и ключевое слово var
    1. Object и упаковка
    2. Dynamic
    3. Var
  8. Типы, допускающие null
  9. Преобразование типов
    1. Неявное и явное приведение
    2. Parse, TryParse и класс Convert
    3. Операторы is и as
  10. Определение типа: typeof и GetType
  11. Значения по умолчанию и default
  12. Как выбрать подходящий тип
  13. Частые вопросы

Система типов в C#

C# относится к языкам со строгой статической типизацией. Каждая переменная, поле, параметр и результат выражения имеют тип, который известен компилятору ещё до запуска программы. Тип определяет, сколько памяти займёт значение, какие значения допустимы и какие операции с ним разрешены. Если попытаться сложить число и логическое значение или передать строку туда, где ожидается целое число, программа просто не соберётся.

Все типы .NET выстроены в единую иерархию, на вершине которой стоит System.Object. Ключевые слова вроде int, string или bool являются псевдонимами (алиасами) для типов из пространства имён System: int это System.Int32, string это System.String, bool это System.Boolean. Разницы в поведении между алиасом и полным именем нет, подробнее об этом написано в статье про различия между String и string.

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

int age = 30;
double price = 199.99;
decimal balance = 1500.50m;
bool isActive = true;
char grade = 'A';
string name = "Анна";

Console.WriteLine($"{name}, {age}, {price}, {balance}, {isActive}, {grade}");

Больше о синтаксисе объявления и правилах именования переменных можно прочитать в материале как объявить переменную в C#.

Значимые и ссылочные типы

Главное деление в системе типов проходит между значимыми (value types) и ссылочными (reference types). От него зависит, как значение хранится в памяти, что происходит при присваивании и как ведёт себя передача в метод.

Значимые типы

Переменная значимого типа хранит само значение. К этой группе относятся все числовые типы, bool, char, перечисления (enum) и структуры (struct). Обычно такие значения лежат в стеке или прямо внутри объекта, которому принадлежат. При присваивании создаётся независимая копия: изменение одной переменной не затрагивает другую.

Ссылочные типы

Переменная ссылочного типа хранит адрес объекта, который находится в управляемой куче. К ним относятся классы, интерфейсы, делегаты, массивы, а также string, object и dynamic. Присваивание копирует только ссылку, поэтому две переменные указывают на один и тот же объект, и изменение через одну из них видно через другую. Освобождением памяти занимается сборщик мусора.

Разницу удобно показать на двух похожих по форме типах, структуре и классе:

PointStruct s1 = new PointStruct { X = 1 };
PointStruct s2 = s1;   // копия значения
s2.X = 99;
Console.WriteLine(s1.X);   // 1

PointClass c1 = new PointClass { X = 1 };
PointClass c2 = c1;    // копия ссылки
c2.X = 99;
Console.WriteLine(c1.X);   // 99

struct PointStruct { public int X; }
class PointClass { public int X; }

Сводка основных отличий выглядит так:

ПризнакЗначимые типыСсылочные типы
Что хранит переменнаяСамо значениеСсылку на объект в куче
ПрисваиваниеКопирует значениеКопирует ссылку
Значение по умолчаниюНоль или аналогnull
Примерыint, double, bool, struct, enumclass, string, массивы, делегаты
Освобождение памятиПри выходе из области видимостиСборщиком мусора

Отдельно стоит помнить про массивы. Сам массив всегда ссылочный тип, даже если его элементы числа: int[] передаётся в метод по ссылке. Подробно о них рассказано в статьях про массивы в C#.

Целочисленные типы

Для хранения целых чисел в C# есть восемь основных типов. Они различаются размером и тем, допускают ли отрицательные значения. Типы со знаком используют один бит под знак, поэтому их положительный диапазон вдвое меньше, чем у беззнаковых аналогов.

ТипРазмерДиапазон
sbyte1 байтот -128 до 127
byte1 байтот 0 до 255
short2 байтаот -32 768 до 32 767
ushort2 байтаот 0 до 65 535
int4 байтаот -2 147 483 648 до 2 147 483 647
uint4 байтаот 0 до 4 294 967 295
long8 байтот -9 223 372 036 854 775 808 до 9 223 372 036 854 775 807
ulong8 байтот 0 до 18 446 744 073 709 551 615

В повседневном коде почти всегда используют int: это тип по умолчанию для целых литералов, его же ожидают индексаторы и свойства вроде Length. Тип long нужен для больших величин, например размеров файлов или меток времени. Тип byte применяют при работе с двоичными данными, потоками и сетью, а short и sbyte встречаются редко, в основном когда важна экономия памяти в больших массивах.

Границы каждого типа доступны через константы MinValue и MaxValue. Выход за границу приводит к переполнению: по умолчанию результат молча оборачивается, а в блоке checked выбрасывается исключение.

int max = int.MaxValue;
Console.WriteLine(max);        // 2147483647
Console.WriteLine(max + 1);    // -2147483648 (переполнение без проверки)

try
{
    int result = checked(max + 1);
}
catch (OverflowException)
{
    Console.WriteLine("Переполнение обнаружено");
}

long big = 5_000_000_000L;     // суффикс L, разделители разрядов
byte flags = 0b_0000_1010;     // двоичный литерал
int hex = 0xFF;                // шестнадцатеричный литерал

Похожая ловушка связана с делением: если оба операнда целые, результат тоже целый, и дробная часть отбрасывается. Почему так происходит и как этого избежать, объяснено в статье про целочисленное деление.

Вещественные типы и decimal

Для чисел с дробной частью предназначены три типа: float, double и decimal. Первые два хранят число в двоичном формате с плавающей точкой и очень быстро обрабатываются процессором, третий хранит десятичное число и рассчитан на точные денежные вычисления.

ТипРазмерПримерный диапазонТочностьСуффикс
float4 байтаот ±1,5e-45 до ±3,4e386-9 знаковf
double8 байтот ±5e-324 до ±1,7e30815-17 знаковd (необязателен)
decimal16 байтот ±1e-28 до ±7,9e2828-29 знаковm

Погрешность чисел с плавающей точкой

Двоичные дроби не могут точно представить многие десятичные числа, например 0,1. Из-за этого сумма 0,1 и 0,2 в double не равна ровно 0,3, и сравнивать такие значения оператором == нельзя. Для сравнения берут допустимую разницу (эпсилон) или переходят на decimal.

double a = 0.1 + 0.2;
Console.WriteLine(a == 0.3);                  // False
Console.WriteLine(Math.Abs(a - 0.3) < 1e-9);   // True

decimal d = 0.1m + 0.2m;
Console.WriteLine(d == 0.3m);                 // True

float f = 3.14f;      // без суффикса f литерал считался бы double
double e = 1.5e3;     // 1500

Когда выбирать decimal

Тип decimal работает медленнее и занимает вдвое больше памяти, чем double, зато хранит десятичные дроби без накопления ошибки. Он подходит для цен, зарплат, налогов и любых расчётов, где важна каждая копейка. Для физических величин, графики и научных вычислений лучше double: скорость там важнее, а небольшая погрешность закономерна. Развёрнутое сравнение с примерами есть в статье выбор между decimal и double.

У вещественных типов есть и особые значения: double.NaN (не число), double.PositiveInfinity и double.NegativeInfinity. Деление вещественного числа на ноль не вызывает исключения, а даёт бесконечность или NaN, тогда как для decimal выбрасывается DivideByZeroException.

Логический и символьный типы

Два небольших типа, без которых не обходится почти ни одна программа, отвечают за условия и отдельные символы.

Тип bool

Тип bool принимает только два значения, true и false. В отличие от C и C++, числа и ссылки в условиях автоматически к логическому типу не приводятся: запись if (count) вызовет ошибку компиляции, нужно писать if (count != 0). Логические значения возвращают операторы сравнения, а над ними работают операции &&, || и !.

Тип char

Тип char хранит один символ в кодировке UTF-16 и занимает два байта. Литерал записывается в одинарных кавычках. Символ можно привести к числу и обратно, потому что внутри это код символа. Помните, что некоторые символы Unicode, например эмодзи, не помещаются в один char и состоят из двух.

bool ok = 5 > 3;
char letter = 'C';
int code = letter;          // 67
char next = (char)(letter + 1);   // 'D'

Console.WriteLine(char.IsDigit('7'));    // True
Console.WriteLine(char.ToUpper('q'));    // Q
Console.WriteLine($"{ok} {code} {next}");

Строки и тип string

Тип string хранит последовательность символов char. Это ссылочный тип, но ведёт он себя почти как значимый: строки неизменяемы. Любая операция, которая как будто меняет строку (Replace, ToUpper, Trim, конкатенация), на самом деле создаёт новый объект, а старый остаётся без изменений.

Из-за неизменяемости многократное склеивание строк в цикле создаёт много временных объектов. Для таких случаев есть класс StringBuilder, который накапливает текст в изменяемом буфере. Сравнение строк оператором == сравнивает содержимое, а не ссылки, чего нельзя сказать о многих других ссылочных типах.

string s = "Привет";
string t = s.ToUpper();
Console.WriteLine(s);   // Привет (исходная строка не изменилась)
Console.WriteLine(t);   // ПРИВЕТ

string path = @"C:\Temp\file.txt";       // буквальная строка
string multi = """
    Многострочный
    текст
    """;                                 // сырая строка, C# 11

int n = 7;
string msg = $"Значение: {n:D3}";        // интерполяция, 007

var sb = new System.Text.StringBuilder();
for (int i = 0; i < 3; i++) sb.Append(i).Append(';');
Console.WriteLine(sb.ToString());       // 0;1;2;

Преобразовать строку в массив символов или разбить на части можно несколькими способами, они собраны в статье про строку в массив.

Типы object, dynamic и ключевое слово var

Эти три средства нередко путают, хотя работают они совершенно по-разному: один хранит любое значение, второй переносит проверки на время выполнения, третий лишь сокращает запись.

Object и упаковка

Тип object является базовым для всех остальных типов, поэтому в переменную object можно положить что угодно. Когда в неё попадает значимый тип, происходит упаковка (boxing): значение копируется в кучу и оборачивается в объект. Обратная операция называется распаковкой (unboxing) и требует явного приведения к точному исходному типу. Упаковка стоит времени и памяти, поэтому в горячих участках кода её стараются избегать, используя обобщённые коллекции вроде List<int> вместо старого ArrayList.

int number = 42;
object boxed = number;        // упаковка
int back = (int)boxed;        // распаковка
Console.WriteLine(back);

// long wrong = (long)boxed;  // InvalidCastException: внутри int, а не long
long right = (int)boxed;      // сначала распаковать как int

Dynamic

Тип dynamic отключает проверку типов на этапе компиляции: обращения к членам разрешаются во время выполнения. Это полезно при работе с COM, динамическими языками и JSON неизвестной структуры, но цена высока. Опечатка в имени метода превратится в исключение RuntimeBinderException уже в работающей программе, а подсказки среды разработки перестанут работать. Использовать dynamic стоит точечно.

Var

Ключевое слово var не создаёт динамический тип. Компилятор выводит тип из правой части инициализации и подставляет его на этапе компиляции, так что переменная остаётся строго типизированной. Объявить var без инициализатора нельзя, а присвоить переменной потом значение другого типа не получится.

var list = new List<string>();   // List<string>
var total = 10 * 2.5;             // double
// total = "текст";               // ошибка компиляции

dynamic x = 10;
x = "теперь строка";              // допустимо
Console.WriteLine(x.Length);      // 13, проверка идёт во время выполнения

Хорошее правило: используйте var, когда тип очевиден из выражения (new, результат понятного метода), и пишите тип явно, когда он помогает читателю понять, что за значение хранится в переменной.

Типы, допускающие null

Значимые типы не могут быть пустыми: int всегда содержит число. Но данные из базы или формы часто бывают не заданы. Для таких случаев есть Nullable<T>, записываемый как T?. Это структура с флагом наличия значения.

int? age = null;
Console.WriteLine(age.HasValue);       // False
Console.WriteLine(age ?? 18);          // 18, значение по умолчанию

age = 25;
if (age is int a)
    Console.WriteLine(a + 1);          // 26

int safe = age.GetValueOrDefault();    // 25
// int bad = age.Value;                // InvalidOperationException, если null

Для ссылочных типов с C# 8 появились аннотации допустимости null. Если в проекте включён <Nullable>enable</Nullable>, то string считается значением, которое не должно быть null, а string? явно допускает его. Компилятор выдаёт предупреждения при возможном разыменовании пустой ссылки. Это один из лучших способов защититься от самой частой ошибки времени выполнения, о которой подробно сказано в разборе NullReferenceException.

Полезные операторы для работы с null: ?? подставляет запасное значение, ??= присваивает его только если переменная пуста, ?. безопасно обращается к члену и возвращает null, если объект пуст.

Преобразование типов

Значения одного типа часто приходится превращать в другой. В C# для этого есть несколько механизмов, которые различаются по безопасности. Более детальный разбор с примерами собран в статье про приведение типов, а ниже даны основные приёмы.

Неявное и явное приведение

Неявное преобразование выполняется автоматически, если оно не может привести к потере данных: из int в long, из float в double, из любого типа в object. Явное преобразование нужно, когда значение может не поместиться или потерять точность. Для него в скобках указывают целевой тип. При приведении дробного числа к целому дробная часть отбрасывается без округления.

int small = 100;
long wide = small;              // неявно, без потерь

double pi = 3.99;
int cut = (int)pi;              // 3, дробная часть отброшена
Console.WriteLine(Math.Round(pi));   // 4

int big = 300;
byte b = (byte)big;             // 44, старшие биты потеряны
Console.WriteLine(b);

Parse, TryParse и класс Convert

Для превращения строки в число используют int.Parse, но при неверном формате он выбрасывает исключение. Если пользователь может ввести что угодно, надёжнее TryParse: он возвращает признак успеха и не создаёт исключения. Класс Convert универсален и умеет работать между многими типами, при этом Convert.ToInt32(null) вернёт 0, а округляет он до ближайшего чётного, а не отбрасывает дробь.

string input = "123abc";

if (int.TryParse(input, out int value))
    Console.WriteLine($"Число: {value}");
else
    Console.WriteLine("Это не число");

int ok = int.Parse("456");
double dbl = Convert.ToDouble("3,14");   // зависит от текущей культуры
Console.WriteLine(Convert.ToInt32(2.5)); // 2 (банковское округление)
Console.WriteLine(Convert.ToInt32(3.5)); // 4

Операторы is и as

Для ссылочных типов существуют безопасные способы проверить и привести тип. Оператор is проверяет совместимость и при необходимости сразу объявляет переменную нужного типа. Оператор as пытается привести объект и при неудаче возвращает null вместо исключения. Обычное приведение в скобках при несовместимых типах бросает InvalidCastException.

object obj = "строка";

if (obj is string text)
    Console.WriteLine(text.Length);

string? maybe = obj as string;       // "строка"
int? notInt = obj as int?;           // null, не исключение

// int wrong = (int)obj;             // InvalidCastException

Определение типа: typeof и GetType

Иногда нужно узнать тип во время выполнения. Оператор typeof принимает имя типа и возвращает объект System.Type; он работает на этапе компиляции, и экземпляр ему не нужен. Метод GetType() вызывается у конкретного объекта и возвращает его фактический тип, даже если переменная объявлена с базовым типом. Эти два механизма часто сравнивают для строгой проверки типа. Оператор is в отличие от них учитывает наследование.

object o = 3.14;

Console.WriteLine(typeof(int));          // System.Int32
Console.WriteLine(o.GetType());          // System.Double
Console.WriteLine(o.GetType() == typeof(double));   // True
Console.WriteLine(o.GetType().Name);     // Double

Console.WriteLine(o is double);          // True
Console.WriteLine(o is IFormattable);    // True, double реализует интерфейс
Console.WriteLine(typeof(string).IsValueType);   // False

Строковое имя типа также пригодится при отладке и логировании. Для обобщённых методов, которым нужен Type в переменной, посмотрите статью про вызов generic-метода через Type.

Значения по умолчанию и default

Если поле класса или элемент массива не инициализированы явно, они получают значение по умолчанию. Для числовых типов это ноль, для bool это false, для char это символ с кодом 0, для ссылочных типов это null. Локальные переменные такого значения не получают: компилятор не даст прочитать переменную, пока ей что-то не присвоено.

Получить значение по умолчанию для любого типа можно ключевым словом default. Это особенно удобно в обобщённом коде, где конкретный тип заранее неизвестен.

Console.WriteLine(default(int));       // 0
Console.WriteLine(default(bool));      // False
Console.WriteLine(default(string) is null);   // True
DateTime dt = default;                 // 01.01.0001 0:00:00
int[] numbers = new int[3];            // {0, 0, 0}

static T Get<T>() => default!;
Console.WriteLine(Get<double>());      // 0

Как выбрать подходящий тип

Несколько практических правил позволяют не ошибиться в большинстве случаев:

  • для счётчиков, индексов и обычных целых используйте int, для очень больших значений long;
  • для денег и точных десятичных расчётов берите decimal, для научных и графических вычислений double;
  • для флагов и условий применяйте bool, а набор взаимоисключающих состояний оформляйте через enum;
  • для текста используйте string, для интенсивной сборки текста StringBuilder;
  • для необязательных значений выбирайте T? вместо магических чисел вроде -1;
  • для небольших неизменяемых наборов данных подойдёт struct или record struct, для остальных случаев class.

Типичные ошибки новичков: сравнение вещественных чисел через ==, использование float для денег, потеря дробной части при делении двух целых, приведение через скобки там, где нужен TryParse, и забытые проверки на null. Если следить за этими местами, большинство проблем с типами исчезает ещё на этапе написания кода.

Частые вопросы

Сколько типов данных в C#?

Встроенных простых типов около пятнадцати: восемь целочисленных, три вещественных (float, double, decimal), bool, char, а также ссылочные string и object. Всего в .NET тысячи типов, потому что любой класс, структура и интерфейс тоже являются типом.

Чем отличается int от Int32?

Ничем. int - это ключевое слово языка, псевдоним для System.Int32. Компилятор обрабатывает обе записи одинаково, различается только стиль. В коде принято использовать короткие ключевые слова, а имя класса удобно при вызове статических членов вроде Int32.Parse.

Является ли string значимым типом?

Нет, string относится к ссылочным типам. Путаница возникает из-за неизменяемости и сравнения по содержимому: строка ведёт себя похоже на значение, но хранится в куче, а переменная содержит ссылку и может быть null.

В чём разница между var и dynamic?

Тип var определяется компилятором один раз при компиляции и далее не меняется, все проверки остаются строгими. Тип dynamic проверяется только при выполнении и позволяет менять тип значения в той же переменной, но ошибки проявляются уже в работающей программе.

Почему 0.1 + 0.2 не равно 0.3?

Типы float и double хранят числа в двоичной системе, где 0,1 и 0,2 записываются бесконечными дробями и поэтому округляются. Сумма даёт число, отличающееся от 0,3 на малую величину. Для точных десятичных вычислений используйте decimal, а вещественные значения сравнивайте с допустимой погрешностью.

Что выбрать для денег в C#?

Тип decimal. Он хранит десятичные дроби точно, поддерживает до 28-29 значащих цифр и обычно используется для сумм, цен и налогов. Литералы этого типа пишутся с суффиксом m, например 19.99m.