Сколько памяти занимает string c
Я не очень знаком с указателями, так как я в основном использую Java, а у Java нет указателей, и сейчас я изучаю C ++. В учебнике по C ++, чтобы узнать размер памяти, занимаемой переменной, он использовал размер указателя на переменную, т.е.
Это сбило меня с толку, потому что на первом курсе меня учили программированию на C, что мне нужно будет делать sizeof для самой переменной. Итак, я сначала пришел к выводу, что они имели в виду то же самое. Но когда я попробовал на своем компьютере, у меня были другие результаты. У меня есть следующий код …
Когда я запускаю приведенный выше код, я получаю выходные данные 4 и 8. Но мой друг скомпилировал и выполнил тот же код на своей машине, и у него были выходные данные 4 и 4. Я не знаю, почему это происходит и почему репетитор использовал sizeof для указателя на переменную вместо самой переменной, так как он хотел знать объем памяти, занимаемый этой переменной. Я знаю, что переменные в C / C ++ имеют различную емкость памяти из-за разной архитектуры, по крайней мере, так меня учили в C. То, что int в 64-битной машине имеет другой размер по сравнению с 32-битной. Но я думаю, что мои результаты должны быть как минимум согласованными, т.е. 8 и 8, или 4 и 4. Я использую 64-битную архитектуру и 64-битную ОС, мой друг использует 64-битную архитектуру с 32-битной ОС.
Решение
Это не указано. Различные реализации могут выделять разные объемы памяти при построении по умолчанию, и реализация не обязана сообщать вам, сколько это памяти. Тем не менее, я считаю, что сейчас это наиболее распространено для std::string использовать оптимизацию короткой строки, в соответствии с которой построена по умолчанию std::string не нужно выделять какую-либо память вообще, кроме размера std::string сам класс. Увидеть Значение аббревиатуры SSO в контексте std :: string для деталей. Обратите внимание, что sizeof(std::string) также не указано.
Другие решения
Хотя это не определено, стоит упомянуть кое-что: на практике реализации избегают выделения памяти для неинициализированных строк.
Я выполнил несколько тестов для обычного sizeof(std::string) с помощью Compiler Explorer (ссылка на тест ). Вот результаты, хотя вы, конечно, можете поэкспериментировать с другими:
- gcc / Linux / x86-64 / libstdc ++: 32 байта
- gcc / Linux / x86 / libstdc ++: 24 байта
- clang / Linux / x86-64 / libc ++ (примечание: не libstdc ++!): 24 байта
- clang / Linux / x86 / libc ++: 12 байт
- среда выполнения msvc / Windows / x86-64 / VC: 32 байта
- среда выполнения msvc / Windows / x86 / VC: 24 байта
- gcc / Linux / ARM64 / libstdc ++: 32 байта
- gcc / Linux / ARM (32-разрядная версия) / libstdc ++: 24 байта
- среда выполнения msvc / Windows / ARM64 / VC: 32 байта (Примечание: вывод в шестнадцатеричном формате на CE)
Грубо говоря, на практике std::string объект будет иметь размер от 12 до 32 байт, за исключением динамически выделяемой памяти.
Эти результаты зависят в основном от реализации стандартной библиотеки и архитектуры процессора (из-за того, что стандартная библиотека чувствует, что делает).
Обратите внимание, что эти размеры делать включить SSO, о котором @Brian рассказывал в своем ответе. Я полагаю, что это мотив для реализации libstdc ++ и MS использовать 32 байта, а не 24 (поскольку я подозреваю, что обычно задействованы 3 указателя: начало данных, конец данных и конец емкости), хотя я не знаю специфики.
Вы всегда можете проверить себя. Это зависит от компьютера, на котором вы находитесь, чтобы проверить размеры типов данных, которые вы всегда можете использовать size:of ,
Решение
дает вам размер переменной p , который имеет тип int * ,
Это не такой же как
который даст вам размер, занимаемый int переменная.
Сказав, что, просто чтобы уточнить, чтобы узнать размер, занимаемый переменной, вам нужно использовать sizeof оператор этой переменной, а не указатель на эту переменную. ( Что вы узнали в первый год является правильный ).
Обратите внимание, что размер указателя зависит от архитектуры, поэтому он может варьироваться. В некоторых архитектурах размер указателя может быть 32 бита ( sizeof вернет 4), в некоторых других это может быть 64 бит ( sizeof вернется 8).
Другие решения
char занимает один байт.
std::string занимает минимум sizeof(std::string) байтов, что, вероятно, составляет 24 байта на современном ПК.
В вашем случае сохраненная память, скорее всего, не имеет значения (поскольку экономия будет очень минимальной, вероятно, 23 байта), поэтому вы должны придерживаться того, что лучше всего описывает ваши намерения. Если вам нужен один символ, то один char будет иметь смысл.
если я объявляю переменную типа std :: string, но не инициализирую ее, сколько памяти выделяется? Я знаю, что если я инициализирую его, например, как «привет», будет зарезервирован байт для каждого символа плюс один для нулевого символа, всего 6. Есть ли длина по умолчанию, определенная где-то в строковом классе?
(Я пытался найти собственно определение в файле заголовка строки, но не знаю, где его найти)
Решение
Что занимает больше памяти, сохраняя один символ в виде символа или строки?
char по определению размер наименьшего адресуемого блока памяти в системе. Поэтому ни один объект не может занимать меньше памяти.
Строка — это последовательность символов. Массив из одного элемента занимает столько же памяти, сколько отдельный элемент:
Однако большинство случаев использования символьной строки требует, чтобы строка заканчивалась нулевым символом. Существует ровно одна строка, которая содержит нулевой терминатор и помещается в массив из одного символа: пустая строка.
Поэтому, если только символ, который вводит пользователь, не является нулем, строка обязательно должна использовать больше памяти, чем один символ — если вам нужно, чтобы он заканчивался нулем.
Если вы рассматриваете std::string , который содержит несколько указателей (которые в большинстве систем больше байта) в дополнение к буферу, который содержит сами символы. (Хотя реализация может быть оптимизирована таким образом, чтобы для небольших строк не выделялся внешний буфер)
Есть ли другие преимущества одного над другим?
Основное преимущество std::string над char является то, что первый может представлять строки произвольной длины, а не один символ.
Другие решения
Вы должны использовать sizeof к самому значению, чтобы узнать, сколько памяти занято переменной. Я думаю, что репетитор сделал неправильно.
sizeof(ptr) дает размер указателя ptr и его размер определяется реализацией. То же самое верно для sizeof(int) , Вот почему вы и ваш друг получаете разные результаты.
Другие возможности, которые вы можете получить в виде 4 4 а также 8 8 ,
Из стандартной тяги N 3690:
« sizeof Оператор возвращает количество байтов в объектном представлении своего операнда. Операнд
либо выражение, которое является неоцененным операндом (раздел 5), либо идентификатор типа в скобках. sizeof
оператор не должен применяться к выражению, имеющему функцию или неполный тип, к типу перечисления
базовый тип которого не зафиксирован до объявления всех перечислителей в массиве времени выполнения
привязано к названию таких типов в скобках или к значению glvalue, обозначающему битовое поле. sizeof(char) ,
sizeof(signed char) а также sizeof(unsigned char) 1. Результат sizeof применяется к любому другому
Фундаментальный тип (3.9.1) определяется реализацией. «
Для компилятора все указатели имеют фиксированный размер независимо от типа данных.
Размер указателя в 32-битном компьютере составляет 4 байта, а в 64-битном размер указателя составляет 8 байтов. Вот почему ваш друг получает 4 в качестве размера указателя, а вы получаете 8.
Итак, начнем с представления строк в памяти
- Длина строки ограничена неким числом в отличие от PWSZ, где длина строки ограничена наличием свободной памяти.
- BSTR строка всегда указывает на первый символ в буфере. PWSZ может указывать на любой символ в буфере.
- У BSTR всегда в конце находится null символ, так же как и у PWSZ, но в отличие от последнего он является валидным символом и может встречаться в строке где угодно.
- За счет наличия null-символа в конце BSTR совместим с PWSZ, но не наоборот.
Использование такой реализации имеет ряд преимуществ: длину строки не нужно пересчитывать она хранится в заголовке, строка может содержать null-символы, где угодно, и самое главное адрес строки(pinned) можно без проблем передавать в неуправляемой код там, где ожидается WCHAR*.
Сколько памяти занимает объект строкового типа?
Мне встречались статьи где было написано, что размер строкового объекта равен size = 20 + (length/2)*4, однако эта формула не совсем правильная.
Начнем с того, что строка является ссылочным типом, поэтому первые 4 байта содержат SyncBlockIndex, а вторые 4 байта содержат указатель на тип.
Размер строки = 4 + 4 + .
Как было выше сказано, в буфере хранится длина строки — это поле типа int, значит еще 4 байта.
Размер строки = 4 + 4 + 4 + .
Для того, чтобы быстро передать строку в неуправляемый код (без копирования) в конце каждой строки стоит null-терминированный символ, который занимает 2 байта, значит
Размер строки = 4 + 4 + 4 + 2 + .
Осталось вспомнить, что каждый символ в строке находится в UTF -16 кодировке значит, занимает так же 2 байта, следовательно
Размер строки = 4 + 4 + 4 + 2 + 2 * length = 14 + 2 * length
Учтем еще один нюанс, и мы у цели. А именно менеджер памяти в CLR выделяет память кратной 4 байтам (4, 8, 12, 16, 20, 24, . ), то есть если длина строки суммарно будет занимать 34 байта, то выделено будет 36 байта. Нам необходимо округлить наше значение к ближайшему большему кратному четырем числу, для этого необходимо:
Размер строки = 4 * ((14 + 2 * length + 3) / 4) (деление естественно целочисленное)
Особенности строк
Итак, мы рассмотрели, как представляются строки, и сколько на самом деле они занимают места в памяти. Теперь давайте погорим об их особенностях.
- Они являются ссылочными типами.
- Они неизменяемы. Однажды, создав строку, мы больше не можем ее изменить (честным способом). Каждый вызов метода этого класса возвращает новую строку, а предыдущая строка становится добычей для сборщика мусора.
- Они переопределяют метод Object.Equals, в результате чего он сравнивает не значения ссылок, а значения символов в строках.
Строки — ссылочные типы
Строки являются настоящими ссылочными типами, то есть они всегда располагаются в куче. Многие путают их со значимыми типами, потому что они ведут себя также, например, они неизменяемы и их сравнение происходит по значению, а не по ссылкам, но нужно помнить, что это ссылочный тип.
Строки — неизменяемы
- Ссылка на массив символов char;
- Индекс первого символа строки в массиве char (смещение он начала);
- Количество символов в строке;
- Посчитанный хэш-код, после первого вызова метода hashCode();
Реализация метода String.substring() в Java:
Однако, согласно принципу ЛДНБ (ланчей даром не бывает), о котором так часто говорит Эрик Липперт не все так хорошо. Если исходная строка будет достаточно большой, а вырезаемая подстрока в пару символов, то весь массив символов первоначальной строки будет висеть в памяти пока есть ссылка на подстроку или, если вы сериализуете полученную подстроку стандартными средствами и передаете её по сети, то будет сериализован весь оригинальный массив и количество передаваемых байтов по сети будет большим. Поэтому в таком случае вместо кода
s = ss.substring(3)
можно использовать код
s = new String(ss.substring(3)),
который не будет хранить ссылку на массив символов исходной строки, а скопирует только реально используемую часть массива. Кстати, если этот конструктор вызывать на строке длиной равной длине массива символов, то копирования в этом случае происходить не будет, а будет использоваться ссылка на оригинальный массив.
Как оказалось в последней версии Java реализация строкового типа изменилась. xonix подсказал об этом. Теперь в классе нет полей offset и length, и появился новый hash32 (с другим алгоритмом хеширования). Это означает, что строки перестали быть персистентными. Теперь метод String.substring каждый раз будет создаваться новую строку.
Строки переопределяют Object.Equals
Класс String переопределяет метод Object.Equals, в результате чего сравнение происходит не по ссылке, а по значению. Я думаю, разработчики благодарны создателям класса String за то, что они переопределили оператор ==, так как код, использующий == для сравнения строк, выглядит более изящно, нежели вызов метода.
Кстати, в Java оператор == сравнивает по ссылке, а для того чтобы сравнить строки посимвольно необходимо использовать метод string.equals().
Интернирование строк
Ну, и на последок поговорим об интернировании строк.
Рассмотрим простой пример, код который переворачивает строку.
Очевидно, данный код не с компилируется. Компилятор будет ругаться на эти строки, потому что мы пытаемся изменить содержимое строки. Действительно, любой метод класса String возвращает новый экземпляр строки, вместо того чтобы изменять свое содержимое.
На самом деле строку можно изменить, но для этого придется прибегнуть к unsafe коду. Рассмотрим пример:
После выполнения этого кода, как и ожидалось, в строке будет записано elbatummi era sgnirtS.
Тот факт, что строки являются все-таки изменяемыми, приводит к одному очень интересному казусу. Связан он с интернированием строк.
Интернирование строк — это механизм, при котором одинаковые литералы представляют собой один объект в памяти.
Если не вникать глубоко в подробности, то смысл интернирования строк заключается в следующем: в рамках процесса (именно процесса, а не домена приложения) существует одна внутренняя хеш-таблица, ключами которой являются строки, а значениями – ссылки на них. Во время JIT-компиляции литеральные строки последовательно заносятся в таблицу (каждая строка в таблице встречается только один раз). На этапе выполнения ссылки на литеральные строки присваиваются из этой таблицы. Можно поместить строку во внутреннюю таблицу во время выполнения с помощью метода String.Intern. Также можно проверить, содержится ли строка во внутренней таблице с помощью метода String.IsInterned.
Важно отметить, что интернируются по умолчанию только строковые литералы. Поскольку для реализации интернирования используется внутренняя хеш-таблица, то во время JIT компиляции происходит поиск по ней, что занимает время, поэтому если бы интернировались все строки, то это свело бы на нет всю оптимизацию. Во время компиляции в IL код, компилятор конкатенирует все литеральные строки, так как нет в необходимости содержать их по частям, поэтому 2 — ое равенство возвращает true. Так вот, в чем заключается казус. Рассмотрим следующий код:
Кажется, что здесь все очевидно и, что такой код должен распечатать Strings are immutable. Однако, нет! Код напечатает elbatummi era sgnirtS. Дело именно в интернировании, изменяя строку s, мы меняем ее содержимое, а так как она является литералом, то интернируется и представляется одним экземпляром строки.
От интернирования строк можно отказаться, если применить специальный атрибут CompilationRelaxationsAttribute к сборке. Атрибут CompilationRelaxationsAttribute контролирует точность кода, создаваемого JIT-компилятором среды CLR. Конструктор данного атрибута принимает перечисление CompilationRelaxations в состав, которого на текущий момент входит только CompilationRelaxations.NoStringInterning — что помечает сборку как не требующую интернирования.
А что если без unsafe?
Таким образом, используя следующий код, можно изменить содержимое строки, даже не прибегая к использованию unsafe коду.
Этот код как уже ожидалось, напечатает elbatummi era sgnirtS.
Особенности производительности
У интернирования есть отрицательный побочный эффект. Дело в том, что ссылка на интернированный объект String, которую хранит CLR, может сохраняться и после завершения работы приложения и даже домена приложения. Поэтому большие литеральные строки использовать не стоит или же, если это необходимо стоит отключить интернирование, применив атрибут CompilationRelaxations к сборке.
Недавно заинтересовался реализацией std::string в libstdc++. Не в связи с принятием нового стандарта, а чтобы разобраться. Благо требования к строковму типу почти не изменились.
Основным средством для анализа кода несомненно является метод пристального вглядывания, но чтобы сузить область вглядывывания и сделать процедуру более захватывающей можно реализовать для строки идиому «трассер» подсмотренную в «C++ Templates: The Complete Guide». Трассировка позволяет выявлять подозрительные интересные операции над строками.
Как известно, std::string это псевдоним для и нам ничего не мешает определить . В X можно определить несколько статических счетчиков и итерировать их в конструкторе, деструкторе и остальных методах. Выполняя разные операции над такой строкой можно будет проследить эффективность применяемых алгоритмов в терминах количества операций.
Кроме того, в g++ для выражение
выведет содержимое строки. Т.е. std::string — является, по сути, указателем на char.
Вобщем, эти и другие шокирующие поднобности под катом.
Структура
std::string декларируется как:
, в свою очередь, является псевдонимом для:
Определение basic_string выполнено в файлах c++/bits/basic_string.h c++/bits/basic_string.tcc.
Если убрать все методы класса, то останется
Почему выбрана именно четверть — мне выяснить так и не удалось.
_S_terminal — символ конца строки он инициализируется конструктором без параметров при запуске программы.
Подсчет ссылок
- -1: строка имеет одну ссылку, увеличение количества ссылок не возможно;
- 0: нормальное значение. существует только одна строка с таким содержанием;
- n>0: существуют n+1 строк с таким содержанием. (при работе в многопоточной программе для таких строк требуются блокировки)
(Upd: _GLIBCXX_FULLY_DYNAMIC_STRING никакого отношения к refcounting'у не имеют. Это простая оптимизация для того, чтобы не выделять память для пустых строк. Отключать её нужно в тех случаях когда у вас несколько экземпляров libstdc++ в процессе (так бывает на Windows) )
В целом эти ссылки просто переносят выделение памяти с вызова констркутора к моменту первой записи в строку и иногда дают о себе знать при работе в мультипоточном режиме. Helgrind и drd выдают нетривиальные подсказки по этому поводу.
Трассер
Посмотрим теперь насколько хорошо методы класса std::string реализуют свои действия. А измерять качество действий будем в количестве операций проводимых над символами строки. Для этого будем использовать идиому «трассер». Она предназначается для отладки контейнеров и заклюается в создании класса подсчитывающего операции производимые над ним. Инстанцируя контейнер таких классов легко можно подсчитать количество оперций сравнения например при сортировке или при выполнении любого другого действия над контейнером. Ну а std::string более или менее подпадает под определение контейнера, соответственно строку можно исследовать с помощью этой идиомы. Можно прикинуть какие конструкторы, методы, алгоритмы более эффективны в тех или иных ситуация. Собственно, вот наш
Для калибровки трассера посмотрим как он подсчитывает операции в элементарных ситуациях:
operation | ctor | copy | assgn | dtor | lss | eql | cast |
---|---|---|---|---|---|---|---|
1 | 0 | 0 | 0 | 0 | 0 | 0 | |
X a; | 1 | 0 | 0 | 0 | 0 | 0 | 0 |
a = '1'; | 0 | 0 | 1 | 0 | 0 | 0 | 0 |
X b = a; | 0 | 1 | 0 | 0 | 0 | 0 | 0 |
X c(a); | 0 | 1 | 0 | 0 | 0 | 0 | 0 |
c('e'); | 0 | 0 | 0 | 0 | 0 | 0 | 1 |
c = b; | 0 | 0 | 1 | 0 | 0 | 0 | 0 |
(c=b)='e'; | 0 | 0 | 2 | 0 | 0 | 0 | 0 |
a < b; | 0 | 0 | 0 | 0 | 1 | 0 | 0 |
a == b; | 0 | 0 | 0 | 0 | 0 | 1 | 0 |
[](X y)->X(b); | 1 | 1 | 0 | 2 | 0 | 0 | 0 |
a = [](X y)->X(b); | 0 | 2 | 1 | 2 | 0 | 0 | 0 |
> | 0 | 0 | 0 | 3 | 0 | 0 | 0 |
В следующей таблице показано конструирование пары массивов:
operation | ctor | copy | assgn | dtor | lss | eql | cast |
---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 | |
X a[10]; | 10 | 0 | 0 | 0 | 0 | 0 | 0 |
std::copy(ch,ch+10,a); | 0 | 0 | 10 | 0 | 0 | 0 | 0 |
X b[10] = ; | 10 | 0 | 0 | 0 | 0 | 0 | 0 |
std::copy(a,a+10,b); | 0 | 0 | 10 | 0 | 0 | 0 | 0 |
> | 0 | 0 | 0 | 20 | 0 | 0 | 0 |
Конструирование пустых строк, как и ожидалось, не производит операций над символами.
operation | ctor | copy | assgn | dtor | lss | eql | cast |
---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 | |
const xs s1; | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
xs s2(s1); | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
xs s3 = s1; | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
> | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
operation | ctor | copy | assgn | dtor | lss | eql | cast |
---|---|---|---|---|---|---|---|
s1.insert(0,s2); | 0 | 0 | 8 | 0 | 0 | 0 | 0 |
s1.insert(0,s2,2,4); | 0 | 0 | 5 | 0 | 0 | 0 | 0 |
s3 = s1; | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
s3 == s1; | 0 | 0 | 0 | 0 | 2 | 0 | 0 |
s2 != s1; | 0 | 0 | 0 | 0 | 1 | 0 | 0 |
s1 > s2; | 0 | 0 | 0 | 0 | 2 | 0 | 0 |
equal(s1.begin(),s1.end(),s2.begin()); | 0 | 0 | 0 | 0 | 0 | 1 | 0 |
mismatch(s1.begin(),s1.end(),s2.begin()); | 0 | 0 | 0 | 0 | 0 | 1 | 0 |
copy(s1.begin(),s1.end(),out_it); | 0 | 0 | 0 | 0 | 0 | 0 | 11 |
std::sort(s1.begin(),s1.end()); | 0 | 10 | 24 | 10 | 29 | 0 | 0 |
std::swap(s1,s2); | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
s3 = s1 + s2; | 0 | 0 | 20 | 0 | 0 | 0 | 0 |
s1 + s2; | 0 | 0 | 20 | 0 | 0 | 0 | 0 |
s3 += s1; | 0 | 0 | 27 | 0 | 0 | 0 | 0 |
s3.substr(10); | 0 | 0 | 16 | 0 | 0 | 0 | 0 |
s4 = s3.substr(10); | 0 | 0 | 16 | 0 | 0 | 0 | 0 |
s4 = s3.find(s2); | 1 | 3 | 2 | 4 | 26 | 8 | 0 |
> | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
Что из этого можно вынести? Ну во первых использовать reserve, а не resize, избегать присвоений и заполняющих конструкторов, воможно, что-то еще. Код работает и дает сходный результат для g++ 4.7.2, intel 13.0.1, clang 3.2, что было проверено здесь
После выхода из области видимости строки деструкторы для символов не вызываются. Возможно там и другие операции пропускаются (например используется memcmp вместо в цикле). Но и строка это не полноценный контейнер. Для строк метод трассеров позволяет получить приблизительную оценку количества операций. Для чистых контейнеров эта оценка должна быть строже.
Вот, почти все.
Во время исследования никто не пострадал. Источниками были
этот референс, файлы:
/usr/include/c++/4.7.2/strings
/usr/include/c++/4.7.2/bits/stringfwd.h
/usr/include/c++/4.7.2./bits/basic_string.h
/usr/include/c++/4.7.2./bits/basic_string.tcc
и gdb.
В качестве постскриптума и иллюстрации тожества метапрограммирования добавлю, что наш трассер можно использовать для оценки затрат на разбор строки в boost::spirit. На гитхабе выложен исходник трассера вместе с примером calc5.cpp из boost::spirit.
Upd1:
Спасибо хабражителю Khim за разъяснение ситуации с _GLIBCXX_FULLY_DYNAMIC_STRING, cо строками c++11 и их реализацией в libstdc++.
После включения в код директивы ,
переопределения и
сборки с опцией -std=gnu++11 подсчет ссылок отключился, что и видно в таблице:
Upd2: Согласно стандарту basic_string может оперировать только с POD типами. Оказалось, что X не является POD типом. (он «standard layout class», но не «trivially copyable class» т.к. имеет явные конструкторы, деструктор и операторы присвоения)
Поэтому поведение класса basic_string является неопределенным, а весь это пост — ложь и провокация. :)
Что и подтвеждается невозможностью скомпилировать примеры с использованием стандартных библиотек отличных от gnu libstdc++. (clang 3.2 c libc++ 1.0 и msvc из vs2012)
Я пишу код, где разные части кода выполняются в зависимости от ввода пользователя. Если я уверен, что пользователь будет вводить только один символ, будет ли строка или символ занимать больше памяти? Есть ли другие преимущества одного пути к другому?
РЕДАКТИРОВАТЬ:
Вот пример того, что я имею в виду:
Читайте также: