Todo este contenido ha sido extraído de los apuntes proporcionados en la asignatura Tecnologías y Paradigmas de la Programación (TPP) de la Universidad de Oviedo, curso 2023-2024.
Primer Programa
Ensamblados
Un assembly es una colección lógica de recursos de una aplicación (archivos .exe, .dll, .ini, .jpg)
Es un componente
Cada proyecto en Visual Studio genera un ensamblado
Los ensamblados poseen un conjunto de módulos (archivos .exe y .dll)
Espacios de nombres
Los namespaces son una agrupación lógica de tipos, al igual que en C++
Se pueden anidar
Para incluir un namespace se usa la declaración using al principio antes de una clase o namespace
using System;
No es posible incluir un único tipo de un namespace
Nivel de Ocultación de una Clase
Una clase puede ser public o internal
Si es interna sólo se podrá acceder a ella desde dentro de su ensamblado (desde su proyecto)
Aquellas clases que se utilizan para implementar una funcionalidad, pero no forman parte de la fachada de un assembly deberían ser internal
C# vs Java
Los namespace se definen entre {}
Para evitar poner siempre el nombre completo de un namespace cuando se haga uso de uno de sus tipos se emplea la palabra using
No se permite incluir un tipo suelto de un namespace
Este mecanismo de control de acceso a tipos y miembros permite consiguir un acoplamiento débil
Modularidad en C#
Ocultación de la Información
Niveles de ocultación de los miembros de una clase:
public: accesible (desde cualquier punto del programa)
private: inaccesible desde fuera de la clase
protected: accesible desde dentro de la clase y sus clases derivadas
internal: accesible desde cualquier clase del ensamblado
protected internal: accesible desde cualquier clase del ensamblado o sus derivadas (aunque no pertenezcan al ensamblado)
El nivel de ocultación por omisión es private
Punto de Entrada
En un programa sólo puede haber un punto de entrada
El punto de entrada deberá ser un método de clase (static) denominado Main
Puede retornar nada (void) o un int
Puede tener cualquier nivel de ocultación
Puede declararse:
Sin parámetros
O con un parámetro de tipo String[] (parámetros pasados por línea de comandos)
Se ofrecen conversiones implícitas cuando no hay pérdida de información
La conversión explícita se lleva a cabo mediante casts
Constantes y Consola
C# permite anteponer const a la declaración de variables y atributos, identificando así las variables cuyo valor no puede modificarse: const double PI = 3.141592
Las constantes:
Las cadenas de caracteres se delimitan por ""
Los carácteres se delimitan por ''
La consola se controla con la clase System.Console
Los métodos Write y WriteLine permiten mostrar texto con formato {índiceParámetro[,alineación][:formato]}
La salida está internacionalizada
Enumeraciones
Las enumeraciones son un conjunto finito de posibles valores
Definen un nuevo tipo con su propio grado de ocultación
Es posible asignarles explícitamente valores enteros:
Operadores
Resumen de los operadores de C#:
Aritméticos: +-*/%
Lógicos: &&||!
De comparación: ==!=>=<=><
Manipulación de bits: &|^~>><<
Asignación: =+=-=*=/=%=&=|=^=<<=>>=
Incremento y Decremento: ++-- (prefijo y postfijo)
Una clase puede ser internal (por omisión) o public
Constructores y destructores
Un constructor define cómo se inicializa un objeto
Se llama de forma implícita justo después de su construcción
Por omisión existe un constructor sin parámetros
Un destructor define la forma de liberar los recursos usados por un objeto
Se llama implícitamente antes de su destrucción
Se ha de llamar ~ seguido del nombre de la clase
No tiene valor de retorno
Se asegura su ejecución pero no de un modo determinista (no sabemos exactamente cuándo se ejecutará)
El recolector de basura ejecutará el destructor de un objeto cuando éste se libere
Objetos
A los objetos se accede a través de referencias
Dentro de un método de instancia (no static) la referencia this nos permite acceder al objeto implícito
Los objetos se crean con el operador new
A un objeto se le pueden pasar mensajes (miembros públicos) con el operador .
Clases parciales
En ocasiones, una clase ofrece un elevado número de miembros
Aunque todos tienen una cohesión, pueden estar a su vez clasificados
Puede ser interesante separarlos físicamente en distintos ficheros / directorios
Para ello se crearon las clases parciales (partial)
Se puede implementar en varios ficheros
Es necesario anteponer la palabra reservada partial
Static
La identificación de miembros de la clase se realiza anteponiendo la palabra reservada static
El acceso a éstos se hace mediante la utilización de la clase, seguida del operador . (Marth.PI)
No es posible usar un objeto como en Java
C# ofrece el concepto de constructor static
Su código se ejecutará cuando se cargue la clase en memoria
Para las clases de utilidad C# ofrece el concepto de static class
Clases de utilidad
Para las clases de utilidad C# ofrece el concepto de static class
Una clase static class de C#:
No permite definir atributos de instancia
No permite definir propiedades de instancia
No permite definir métodos de instancia
No permite definir constructores de instancia
Propiedades
C# ofrece el concepto de propiedad para acceder al estado de los objetos como si de atributos se tratase, obteniendo los beneficios del encapsulamiento:
Se oculta el estado interno del objeto, ofreciendo un acceso indirecto mediante las propiedades (encapsulamiento)
Se puede cambiar la implementación de la propiedad sin modificar el acceso por parte del cliente
Las propiedades pueden ser de lectura y/o escritura
Las propiedades en C# pueden:
Catalogarse con todos los niveles de ocultación
Ser de clase (static)
Ser abstractas
Sobreescribirse (enlace dinámico)
Strings
El tipo string forma parte del lenguaje C#
Es un alias de la clase System.String
Por tanto:
string y String son clases
sus instancias son objetos
las variables de este tipo, son referencias
El operador + concatena strings
Los objetos de tipo string son inmutables: no se puede cambiar o modificar su estado
Si queremos modificar su estado, debe usarse la clase StringBuilder
Arrays
Los arrays son estructuras de datos con múltiples valores de un mismo tipo
Un array es un objeto
Para acceder a éste necesitamos una referencia
Una referencia de un array se declara así:
int[] arrayEnteros
bool[] arrayLogicos
Angulo[] arrayObjetoAngulo
Un array se crea con el operador new, indicando además el tamaño del array:
arrayEnteros=new int[10];
vectorValoresLógicos=new bool[2];
vectorObjetosAngulo=new Angulo[91];
Los arrays se indexan desde 0 hasta longitud -1
En el caso de los arrays de objetos, tenemos reservado espacio para referencias, no para los objetos:
C# posee una sintaxis para crear arrays con una inicialización previa:
Los distintos valores de los elementos del array se enumeran dentro de {}, separados por comas:
Los arrays poseen una propiedad Length que devuelve el número de elementos de un array (bucle for y foreach)
Arrays Multidimensionales
Existen dos alternativas para crear arrays de varias dimensiones:
Arrays lineales: memoria contigua lineal, de varias dimensiones
Más eficientes, menos versátiles
Length es el producto de los tamaños
El tamaño de cada dimensión se puede obtener con GetLength
Arrays de Arrays: permite la construcción de arrays irregulares
Structs
En C# un conjunto de campos públicos se puede representar como un Struct
Pueden tener constructores, propiedades, métodos, campos, operadores y miembros de clase (static)
Los structs no pueden heredar de otras clases o structs
Implícitamente derivan de ValueType
Sí pueden implementar interfaces
No pueden tener destructores
La ocultación de sus miembros es, por omisión, public
Aunque se creen con new, ¡siempre se almacenan en la pila!
Se crearon para ser usados en la transferencia de datos y no sobrecargar el recolector de basura
Paso de Parámetros
C# posee 3 tipos de paso de parámetros:
Paso por valor
El parámetro formal es una copia del parámetro real (argumento)
En el paso de objetos, lo que se copia es la referencia →¡El objeto es el original!
NOTA: no se modifica el valor
Paso por referencia de entrada y salida:
El parámetro formal es un alias del parámetro real (argumento)
Se pasa con un valor (entrada) y se le puede asignar otro (salida), modificando el original
Se utiliza la palabra reservada ref tanto en el parámetro como en el argumento
NOTA: se modifica el valor
Paso por referencia de salida:
El parámetro formal es un alias del parámetro real (argumento)
Se pasa sin valor (entrada) y sirve para devolver más de un valor
Se usa la palabra reservada out tanto en el parámetro como en el argumento
Salida:
Parámetros Opcionales
Es posible asignar valores por omisión a los parámetros
SIEMPRE tienen que ser los últimos (más a la derecha)
Permite:
Invocaciones a una función con distinto número de argumentos
Evitar el abuso de la sobrecarga
No es necesario pasar todos los parámetros con valor por omisión anteriores (se permite “saltar parámetros por omisión”)
Puede usarse para mejorar la documentación del código, nombrando los parámetros en la invocación
Sobrecarga de métodos
Permite dar distintas implementaciones a un mismo identificador de método
En C# para sobrecargar un método es necesario modificar:
O número de parámetros
O el tipo de alguno de sus parámetros
O el paso de alguno de sus parámetros (valor, ref o out)
Sobrecarga de operadores
Emplea la palabra reservada operator
Los operadores son siempre métodos de clase (static) →No son polimórficos
Sobrecargando un operador (+), obtenemos automáticamente, si ha lugar, su asignación (+=)
Sobrecargando ++ o -- prefijo, obtenemos automáticamente su versión postfija
Permite sobrecargar la conversión explícita (cast) y la implícita de tipos del lenguaje
Puesto que la invocación a un método no es un lvalue, implementa el operador [] con un tipo de propiedad ad hoc→los indexers
WTF esto es de DLP (asignatura de 3)
Declaración implícita de variables
No se requiere especificar su tipo siempre que se asigne un valor en la declaración:
Esto es útil cuando:
Los tipos poseen nombres largos (debido a la genericidad)
No es sencillo identificar el tipo de la expresión (LINQ)
No existe un tipo explícito (los tipos anónimos)
Métodos extensores
Posibilidad de añadir métodos a clases de las que no poseemos el código (String, Int32, IEnumerable…)
Para ello:
Hay que implementar un método de clase (static)
En una clase de utilidad (static)
Su primer parámetro no tiene que ser del tipo que deseamos ampliar
El primer parámetro tiene que declararse anteponiendo la palabra reservada this
Se creó para implementar LINQ, añadiendo métodos a IEnumerable e IQueryable
Una función de primer orden (o nivel) es aquella que no recibe otra función como parámetro
Una función de orden superior es aquella que recibe una o más funciones como parámetro
Func, Predicate y Action
Func<T> o Func<T1,T2>: siempre devuelve algo (no tiene por qué tener parámetros)
Action o Action<T>: método que no devuelve nunca nada (puede tener o no parámetros)
Predicate<T>: método que retorna un bool y recibe un T (sólo recibe un único parámetro)
Clausura
Una clausura es una función de primer nivel junto con su ámbito (una tabla que guarda las referencias a sus variables libres)
Las variables libres de una clausura representan estado
Por tanto, pueden representar objetos
Las clausuras también pueden representar estructuras de control:
Ejemplos de clausura funcional
Salida:
Currificación y aplicación parcial
La currificación(currying) es la técnica para transformar una función de varios parámetros en una función que recibe un único parámetro
La función recibe un parámetro y retorna otra función que se puede llamar con el segundo parámetro
Esto puede repetirse para todos los parámetros de la función original
La invocación se convierte en llamadas encadenadas: f(1) (2) (3)(currificado) vs f(1,2,3)(no currificado)
La aplicación parcial , cuando las funciones ya están currificadas, consiste en pasar un número menor de parámetros en la invocación de la función
El resultado es otra función con un número menor en su aridad (número de parámetros)
Ejemplos de Currificación
Otro ejemplo (hecho por mí y para mí muchísimo más visual con aplicación parcial y currificación):
Generadores
Un generador es una función que simula la devolución de una colección de elementossin construir toda la coleccióndevolviendo un elemento cada vez que la función es invocada
El hecho de no construir toda la colección hace que sea más eficiente
Un generador es una función que se comporta como un iterador
Se usa la palabra reservada yield
Otro ejemplo
Evaluación Perezosa
La evaluación perezosa (lazy) es la técnica por la que se demora la evaluación de una expresión hasta que ésta es utilizada
Es lo contrario de una evaluación ansiosa (eager) o estricta (strict)
C#no ofrece evaluación perezosa de un modo directo
Puesto que la generación de elementos es perezosa, podemos generar colecciones de un número potencialmente infinito de números con yield y hacer uso de ellas con los siguientes métodos extensores:
Skip: para saltarnos X elementos de una secuencia, retornando los restantes
Take: para retornar X elementos contiguos desde el principio de una secuencia
Ejemplo de Evaluación perezosa en C#
Salida:
Variables globales y Asignaciones
Las variables mutables fuera del ámbito de una función hacen que no se obtenga transparencia referencial
La clausura RetornaContador devuelve el número de veces que había sido invocada (depende de su “historia”) → No puede sustituirse por un valor
Con las asignaciones sucede lo mismo
La evaluación de una variable depende de sus asignaciones previas
Funciones Puras
La utilización de funciones que no son puras implican opacidad referencial
Una función es pura cuando:
Siempre devuelve el mismo valor ante los mismos valores de los argumentos
La evaluación de una función no genera efectos secundarios ((co)laterales)
Ejemplos de funciones no puras: DateTime::Now,Random::Random,Console::ReadLine
Ejemplos de funciones puras: Math::Sin,String::Length,DateTime::ToString
Memoización
La memoización es una técnica de optimización que puede ser aplicada sobre expresiones con transparencia referencial
La primera vez que se invoca, se retorna el valor guardándolo en una caché (un Dictionary)
En sucesivas invocaciones se retornará el valor de la caché sin necesidad de ejecutar la función
Evaluación Perezosa (revisitada)
Recordemos que en el paso de parámetros perezoso se demora la evaluación de un parámetro hasta que éste sea utilizado
Este comportamiento se puede conseguir
Haciendo que los parámetros sean funciones (de orden superior)
Memoizando su evaluación
Filter, Map, Reduce
Filter (Where): aplica un filtrado a todos los elementos de una colección, devolviendo otra colección con aquellos elementos que satisfagan el predicado
Map (Select): aplica una función a todos los elementos de una colección, devolviendo otra nueva colección con los resultados obtenidos
Reduce (Aggregate): se aplica una función a todos los elementos de una lista y se devuelve un valor
Ejemplos de Select (Map)
Ejemplos de Where (Filter)
Ejemplos de Reduce (Aggregate)
Otras funciones
Fundamentos de la programación concurrente y paralela
Ley de Moore
Ley empírica que dice lo siguiente:
El número de transistores por unidad de superficie en circuitos integrados se duplica cada 24 meses, sin encarecer su precio
Lo que indica que en dos años, por el mismo precio, tendremos un microprocesador el doble de potente
Programación concurrente
Concurrencia es la propiedad por la que varias tareas se pueden ejecutar simultáneamente y potencialmente interactuar entre sí
Las tareas se pueden ejecutar en varios núcleos, en varios procesadores, o simulada en un único procesador
Las tareas pueden ser hilos o procesos
Programación paralela
Paralelismo es un caso particular de la concurrencia, en el que las tareas se ejecutan de forma paralela (simultáneamente, no simulada)
Con la concurrencia, la simultaneidad puede ser simulada
Con el paralelismo, la simultaneidad debe ser real
El paralelismo comúnmente enfatiza la división de un problema en partes más pequeñas
La programación concurrente comúnmente enfatiza la interacción entre tareas
Proceso
Un proceso es un programa de ejecución
Consta de instrucciones, estado de ejecución y valores de los datos en ejecución
En los sistemas de memoria distribuida, las tareas concurrentes en distintos procesadores son procesos
Todo proceso tiene un identificador único (PID)
Procesos en .NET
Se abstraen con instancias de la clase Process en System.Diagnostics:
Hilo
Un proceso puede constar de varios hilos de ejecución (threads)
Un hilo de ejecución es una tarea de un proceso que puede ejecutarse concurrentemente, compartiendo la memoria del proceso, con el resto de sus hilos
Hilos en .NET
Los hilos se abstraen con instancias de Thread en System.Threading:
Paralelización de algoritmos
Existen dos escenarios típicos de paralelización:
Paralelización de tareas: tareas independientes pueden ser ejecutadas concurrentemente
Paralelización de datos: ejecutar una misma tarea que computa porciones de los mismos datos
Paso asíncrono de mensajes
Una primera aproximación para crear programas paralelos es el paso de mensajes asíncrono
Cada mensaje asíncrono crea (potencialmente, no necesariamente) un nuevo hilo (thread)
En C# esta funcionalidad se obtiene mediante:
delegados
Tasks
Para conseguir el paso asíncrono de mensajes, haremos uso del async y await
async y await
Se apoya en el uso de objetos Task y Task<T>
Modifican el orden habitual de ejecución del programa
El código asíncrono es muy parecido al código síncrono
await
Se aplica sobre una expresión sobre la que se puede esperar: expression.GetAwaiter()
Normalmente expresiones de tipo Task y Task<T>
async
Se aplica a métodos que en su cuerpo usan await
Se lo contrario se comporta como un método síncrono y al compilar produce un warning
Haciendo uso del POO, la clase Thread encapsula un hilo de ejecución de forma explícita
Ejemplo de uso
Cálculo paralelizado del módulo de un vector
Se usará el Master-Worker
Thread.Join
Cuando se llama al Join, el hilo que realiza la llamada se bloquea (duerme) hasta que finaliza la ejecución del Thread que recibió el mensaje
Condición de Carrera
¿Qué pasaría si no hubiésemos puesto la llamada a Thread.Join?
El hilo master tomaría los resultados del cálculo (posiblemente) antes de que hubiesen acabado los hilos worker de hacer el cómputo
El cálculo final varía de una ejecución a otra
Se dice que múltiples tareas están en una condición de carrera (race condition) cuando su resultado depende del orden en el que éstas se ejecutan
Un programa concurrente no debe tener condiciones de carrera
Las condiciones de carrera son un foco de errores en programas y sistemas concurrentes
Parámetros en los hilos
Si se requiere un enfoque más funcional, se pueden pasar parámetros a los hilos:
Los hilos siempre reciben un delegado de tipo Action
Variables libres (free)
Si se usan funciones lambda, hay que tener cuidado con sus variables libres
Cada hilo posee una copia de la pila de ejecución a partir del ámbito en el que se creó
Las variables locales ya declaradas serán compartidas por todos los hilos
Alternativas a variables libres
Paso de parámetros (preferible)
Copia de variables
Context Switch
El contexto de una tarea (hilo o proceso) es la información que tiene que ser guardada cuando ésta es interrumpida para que luego pueda reanudarse su ejecución
El cambio de contexto (context switch) es la acción de almacenar/restaurar el contexto de una tarea (hilo o proceso) para que pueda ser reanudada su ejecución
Esto permite la ejecución concurrente de varias tareas en un mismo procesador
El cambio de contexto requiere
Tiempo de computación para almacenar y restaurar el contexto de varias tareas
Memoria adicional para almacenar los distintos contextos
Por tanto, la utilización de un número elevado de tareas, en relación con el número de procesadores (cores), puede conllevar una caída global del rendimiento
Análisis del Context Switch
Con 2 hilos (y con 4) se obtiene el mejor rendimiento
A partir de 28 hilos, el rendimiento de la aplicación decae frente a un algoritmo secuencial
Cuando se usan más de 9 hilos, el rendimiento decae linealmente frente al número de hilos (línea roja)
Tareas (Task)
Tienen un nivel de abstracción mayor y proporcionan más funcionalidades que los hilos, facilitando la programación paralela
Puesto que el orden de ejecución de los hilos no es determinista, es necesario utilizar mecanismos de sincronización de hilos para evitar condiciones de carrera
El mecanismo básico es Thread.Join
No obstante, la necesidad más típica de sincronización de hilos es por acceso concurrente a recursos compartidos
Un recurso compartido puede ser un dispositivo físico (impresora), lógico (fichero), una estructura de datos, un objeto e incluso una variable
Evitar el uso simultáneo de un recurso compartido se denomina exclusión mutua
Ejemplo
Recurso compartido
En el código anterior, el recurso compartido es la salida estándar de la consola
El hecho de no proteger su acceso, hace que las instrucciones:
No se ejecuten de forma atómica
Una sección crítica es un fragmento de código que accede a un recurso compartido que no debe ser accedido concurrentemente por más de un hilo de ejecución
La sincronización de hilos debe usarse para conseguir la máxima exclusión mutua
Lock
La principal técnica para sincronizar hilos en C# es la palabra reservada lock
Consigue que únicamente un hilo pueda ejecutar una sección de código (sección crítica) simultáneamente →exclusión mútua
lock requiere especificar un objeto (referencia) como parámetro:
El objeto modela un padlock:
Un hilo bloquea el objeto
Ejecuta la secciíon crítica
Sale del bloqueo
Si otro hilo ejecuta el lock sobre un objeto que ya está bloqueado, entonces se pondrá en modo de espera y se bloqueará hasta que el objeto sea liberado
Asignaciones
No todas las asignaciones son atómicas
Las asignaciones de 32 bits son atómicas
Las asignaciones de 64 bits (long, ulong, double, decimal) no son atómicas en un SO de 32 bits
Los operadores +=, -=, *=, /= … no son atómicos
Los operadores ++, -- no son atómicos
Por tanto, las asignaciones multihilo de una misma variable deben sincronizarse
Una alternativa es usar lock
Otra alternativa es usar los métodos de la clase Interlocked (System.Threading)
Esta alternativa es mucho más eficiente que usar lock
Los métodos son Increment, Decrement y Exchange entre otros
Interbloqueo (deadlock)
Se produce un interbloqueo (deadlock) entre un conjunto de tareas si todas y cada una de ellas están esperando por un evento que sólo otra puede causar
Todas las tareas se bloquean de forma permanente
El caso más común es el acceso a recursos compartidos